From 418324e2f6185b2dcebf9e69b9e20c2c208590e3 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:37:18 -0400 Subject: [PATCH 01/30] Format and refactor * Also allow click to place marker --- lib/src/models/view/map.dart | 27 ++-- lib/src/pages/map.dart | 298 ++++++++++++++++++++--------------- 2 files changed, 185 insertions(+), 140 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index f9344a963..3dfebba97 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -38,6 +38,12 @@ class GridOffset { const GridOffset(this.x, this.y); } +/// A record representing data necessary to display a cell in the map +typedef MapCellData = ({GpsCoordinates coordinates, AutonomyCell cellType}); + +/// A 2D array of [MapCellData] to represent a coordinate grid +typedef AutonomyGrid = List>; + /// A view model for the autonomy page to render a grid map. /// /// Shows a bird's-eye map of where the rover is, what's around it, where the goal is, and the path @@ -85,10 +91,10 @@ class AutonomyModel with ChangeNotifier { } /// An empty grid of size [gridSize]. - List> get empty => [ + AutonomyGrid get empty => [ for (int i = 0; i < gridSize; i++) [ for (int j = 0; j < gridSize; j++) - (GpsCoordinates(), AutonomyCell.empty), + (coordinates: GpsCoordinates(longitude: -j.toDouble() + offset.x, latitude: i.toDouble() - offset.y), cellType: AutonomyCell.empty), ], ]; @@ -107,7 +113,7 @@ class AutonomyModel with ChangeNotifier { AutonomyData data = AutonomyData(); /// The grid of size [gridSize] with the rover in the center, ready to draw on the UI. - List> get grid { + AutonomyGrid get grid { final result = empty; for (final obstacle in data.obstacles) { markCell(result, obstacle, AutonomyCell.obstacle); @@ -128,10 +134,10 @@ class AutonomyModel with ChangeNotifier { /// Converts a decimal GPS coordinate to an index representing the block in the grid. int gpsToBlock(double value) => (value / models.settings.dashboard.mapBlockSize).round(); - /// Calculates a new position for [gps] based on [offset] and adds it to the [list]. + /// Calculates a new position for [gps] based on [offset] and adds it to the [grid]. /// /// This function filters out any coordinates that shouldn't be shown based on [gridSize]. - void markCell(List> list, GpsCoordinates gps, AutonomyCell value) { + void markCell(AutonomyGrid grid, GpsCoordinates gps, AutonomyCell value) { // Latitude is y-axis, longitude is x-axis // The rover will occupy the center of the grid, so // - rover.longitude => (gridSize - 1) / 2 @@ -141,7 +147,7 @@ class AutonomyModel with ChangeNotifier { final y = gpsToBlock(gps.latitude) + offset.y; if (x < 0 || x >= gridSize) return; if (y < 0 || y >= gridSize) return; - list[y][x] = (gps, value); + grid[y][x] = (coordinates: gps, cellType: value); } /// Determines the new [offset] based on the current [roverPosition]. @@ -177,10 +183,9 @@ class AutonomyModel with ChangeNotifier { notifyListeners(); } - /// Places the marker in [markerBuilder]. - void placeMarker() { - markers.add(markerBuilder.value); - markerBuilder.clear(); + /// Places the marker at [coordinates]. + void placeMarker(GpsCoordinates coordinates) { + markers.add(coordinates); notifyListeners(); } @@ -192,7 +197,7 @@ class AutonomyModel with ChangeNotifier { /// Removes a marker in [gps] void updateMarker(GpsCoordinates gps) { - if(markers.remove(gps)){ + if (markers.remove(gps)) { notifyListeners(); } else { models.home.setMessage(severity: Severity.info, text: "Marker not found"); diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index da154ff91..bcdd6ad0f 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -5,151 +5,191 @@ import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/widgets.dart"; /// The UI for the autonomy subsystem. -/// +/// /// Displays a bird's-eye view of the rover and its path to the goal. class MapPage extends ReactiveWidget { - /// Gets the color for a given [AutonomyCell]. - Color? getColor(AutonomyCell cell) => switch(cell) { - AutonomyCell.destination => Colors.green, - AutonomyCell.obstacle => Colors.black, - AutonomyCell.path => Colors.blueGrey, - AutonomyCell.empty => Colors.white, - AutonomyCell.marker => Colors.red, - AutonomyCell.rover => Colors.transparent, - }; + /// Gets the color for a given [AutonomyCell]. + Color? getColor(AutonomyCell cell) => switch (cell) { + AutonomyCell.destination => Colors.green, + AutonomyCell.obstacle => Colors.black, + AutonomyCell.path => Colors.blueGrey, + AutonomyCell.empty => Colors.white, + AutonomyCell.marker => Colors.red, + AutonomyCell.rover => Colors.transparent, + }; - /// Opens a dialog to prompt the user for GPS coordinates and places a marker there. + /// Opens a dialog to prompt the user for GPS coordinates and places a marker there. void placeMarker(BuildContext context, AutonomyModel model) => showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text("Add a Marker"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ GpsEditor(model.markerBuilder) ], - ), - actions: [ - TextButton(child: const Text("Cancel"), onPressed: () => Navigator.of(context).pop()), - ElevatedButton( - onPressed: model.markerBuilder.isValid ? () { model.placeMarker(); Navigator.of(context).pop(); } : null, - child: const Text("Add"), + context: context, + builder: (_) => AlertDialog( + title: const Text("Add a Marker"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [GpsEditor(model.markerBuilder)], + ), + actions: [ + TextButton(child: const Text("Cancel"), onPressed: () => Navigator.of(context).pop()), + ElevatedButton( + onPressed: model.markerBuilder.isValid + ? () { + model.placeMarker(model.markerBuilder.value); + model.markerBuilder.clear(); + Navigator.of(context).pop(); + } + : null, + child: const Text("Add"), + ), + ], ), - ], - ), - ); + ); /// The index of this view. final int index; + /// A const constructor. const MapPage({required this.index}); @override AutonomyModel createModel() => AutonomyModel(); - @override - Widget build(BuildContext context, AutonomyModel model) => Stack(children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 48), - for (final row in model.grid.reversed) Expanded( - child: Row(children: [ - for (final cell in row) Expanded( - child: GestureDetector( - onTap: () => cell.$2 != AutonomyCell.marker ? () : model.updateMarker(cell.$1), - child: Container( - width: 24, - decoration: BoxDecoration(color: getColor(cell.$2), border: Border.all()), - child: cell.$2 != AutonomyCell.rover ? null : Container( - color: Colors.blue, - width: double.infinity, - height: double.infinity, - margin: const EdgeInsets.all(4), - child: Transform.rotate( - angle: -model.roverHeading * pi / 180, - child: const Icon(Icons.arrow_upward, size: 24), + /// Creates a widget to display the cell data for the provided [cell] + Widget createCell(AutonomyModel model, MapCellData cell) => Expanded( + child: GestureDetector( + onTap: () { + if (cell.cellType == AutonomyCell.marker) { + model.updateMarker(cell.coordinates); + } else if (cell.cellType == AutonomyCell.empty) { + model.placeMarker(cell.coordinates); + } + }, + child: Container( + width: 24, + decoration: BoxDecoration(color: getColor(cell.cellType), border: Border.all()), + child: cell.cellType != AutonomyCell.rover + ? null + : Container( + color: Colors.blue, + width: double.infinity, + height: double.infinity, + margin: const EdgeInsets.all(4), + child: Transform.rotate( + angle: -model.roverHeading * pi / 180, + child: const Icon(Icons.arrow_upward, size: 24), + ), ), - ), - ), - ), ), - ],), - ), - const SizedBox(height: 4), - if (!model.isPlayingBadApple) Row(children: [ // Legend - const SizedBox(width: 4), - Text("Legend:", style: context.textTheme.titleLarge), - const SizedBox(width: 8), - Container(width: 24, height: 24, color: Colors.blue), - const SizedBox(width: 4), - Text("Rover", style: context.textTheme.titleMedium), - const SizedBox(width: 24), - Container(width: 24, height: 24, color: Colors.green), - const SizedBox(width: 4), - Text("Destination", style: context.textTheme.titleMedium), - const SizedBox(width: 24), - Container(width: 24, height: 24, color: Colors.black), - const SizedBox(width: 4), - Text("Obstacle", style: context.textTheme.titleMedium), - const SizedBox(width: 24), - Container(width: 24, height: 24, color: Colors.blueGrey), - const SizedBox(width: 4), - Text("Path", style: context.textTheme.titleMedium), - const SizedBox(width: 24), - Container(width: 24, height: 24, color: Colors.red), - const SizedBox(width: 4), - Text("Marker", style: context.textTheme.titleMedium), - const Spacer(), - Text("Zoom: ", style: context.textTheme.titleLarge), - Expanded(flex: 2, child: Slider( - value: model.gridSize.toDouble(), - min: 1, - max: 41, - divisions: 20, - label: "${model.gridSize}x${model.gridSize}", - onChanged: (value) => model.zoom(value.toInt()), - ),), - ],), - if (!model.isPlayingBadApple) Row(children: [ // Controls - const SizedBox(width: 4), - Text("Place marker: ", style: context.textTheme.titleLarge), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text("Add Marker"), - onPressed: () => placeMarker(context, model), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.location_on), - label: const Text("Drop marker here"), - onPressed: model.placeMarkerOnRover, ), - const SizedBox(width: 8), - ElevatedButton.icon(icon: const Icon(Icons.clear), label: const Text("Clear all"), onPressed: model.clearMarkers), - const Spacer(), - ],), - const SizedBox(height: 8), - AutonomyCommandEditor(model), - const VerticalDivider(), - const SizedBox(height: 4), - ],), - Container( - color: context.colorScheme.surface, - height: 50, - child: Row(children: [ // The header at the top - const SizedBox(width: 8), - Text("Map", style: context.textTheme.headlineMedium), - if (models.settings.easterEggs.badApple) IconButton( - iconSize: 48, - icon: CircleAvatar( - backgroundImage: const AssetImage("assets/bad_apple_thumbnail.webp"), - child: model.isPlayingBadApple ? const Icon(Icons.block, color: Colors.red, size: 36) : null, + ); + + @override + Widget build(BuildContext context, AutonomyModel model) => Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + for (final row in model.grid.reversed) + Expanded( + child: Row( + children: [ + for (final cell in row) createCell(model, cell), + ], + ), + ), + const SizedBox(height: 4), + if (!model.isPlayingBadApple) + Row( + children: [ + // Legend + const SizedBox(width: 4), + Text("Legend:", style: context.textTheme.titleLarge), + const SizedBox(width: 8), + Container(width: 24, height: 24, color: Colors.blue), + const SizedBox(width: 4), + Text("Rover", style: context.textTheme.titleMedium), + const SizedBox(width: 24), + Container(width: 24, height: 24, color: Colors.green), + const SizedBox(width: 4), + Text("Destination", style: context.textTheme.titleMedium), + const SizedBox(width: 24), + Container(width: 24, height: 24, color: Colors.black), + const SizedBox(width: 4), + Text("Obstacle", style: context.textTheme.titleMedium), + const SizedBox(width: 24), + Container(width: 24, height: 24, color: Colors.blueGrey), + const SizedBox(width: 4), + Text("Path", style: context.textTheme.titleMedium), + const SizedBox(width: 24), + Container(width: 24, height: 24, color: Colors.red), + const SizedBox(width: 4), + Text("Marker", style: context.textTheme.titleMedium), + const Spacer(), + Text("Zoom: ", style: context.textTheme.titleLarge), + Expanded( + flex: 2, + child: Slider( + value: model.gridSize.toDouble(), + min: 1, + max: 41, + divisions: 20, + label: "${model.gridSize}x${model.gridSize}", + onChanged: (value) => model.zoom(value.toInt()), + ), + ), + ], + ), + if (!model.isPlayingBadApple) + Row( + children: [ + // Controls + const SizedBox(width: 4), + Text("Place marker: ", style: context.textTheme.titleLarge), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text("Add Marker"), + onPressed: () => placeMarker(context, model), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.location_on), + label: const Text("Drop marker here"), + onPressed: model.placeMarkerOnRover, + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.clear), label: const Text("Clear all"), onPressed: model.clearMarkers), + const Spacer(), + ], + ), + const SizedBox(height: 8), + AutonomyCommandEditor(model), + const VerticalDivider(), + const SizedBox(height: 4), + ], ), - onPressed: model.isPlayingBadApple ? model.stopBadApple : model.startBadApple, - ), - const Spacer(), - ViewsSelector(index: index), - ],), - ), - ],); + Container( + color: context.colorScheme.surface, + height: 50, + child: Row( + children: [ + // The header at the top + const SizedBox(width: 8), + Text("Map", style: context.textTheme.headlineMedium), + if (models.settings.easterEggs.badApple) + IconButton( + iconSize: 48, + icon: CircleAvatar( + backgroundImage: const AssetImage("assets/bad_apple_thumbnail.webp"), + child: model.isPlayingBadApple ? const Icon(Icons.block, color: Colors.red, size: 36) : null, + ), + onPressed: model.isPlayingBadApple ? model.stopBadApple : model.startBadApple, + ), + const Spacer(), + ViewsSelector(index: index), + ], + ), + ), + ], + ); } From 42ea897a66a8381b5f4dd468676341b46340405e Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Mon, 30 Sep 2024 18:14:28 -0400 Subject: [PATCH 02/30] Rearranged the entire page --- lib/src/pages/map.dart | 235 ++++++++++++++++++++++++----------------- 1 file changed, 136 insertions(+), 99 deletions(-) diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index bcdd6ad0f..255b598bd 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -81,115 +81,152 @@ class MapPage extends ReactiveWidget { ), ); + /// The controls for creating, placing, and removing markers + Widget markerControls(BuildContext context, AutonomyModel model) => Row( + children: [ + // Controls + const SizedBox(width: 4), + Text("Place marker: ", style: context.textTheme.titleLarge), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text("Add Marker"), + onPressed: () => placeMarker(context, model), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.location_on), + label: const Text("Drop marker here"), + onPressed: model.placeMarkerOnRover, + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.clear), + label: const Text("Clear all"), + onPressed: model.clearMarkers, + ), + const Spacer(), + Text("Zoom: ", style: context.textTheme.titleLarge), + Expanded( + flex: 2, + child: Slider( + value: model.gridSize.toDouble(), + min: 1, + max: 41, + divisions: 20, + label: "${model.gridSize}x${model.gridSize}", + onChanged: (value) => model.zoom(value.toInt()), + ), + ), + ], + ); + @override - Widget build(BuildContext context, AutonomyModel model) => Stack( + Widget build(BuildContext context, AutonomyModel model) => Column( children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 48), - for (final row in model.grid.reversed) + MapPageHeader(model: model, index: index), + const SizedBox(height: 24), + Expanded( + child: Row( + children: [ + if (!model.isPlayingBadApple) ...[ + const SizedBox(width: 16), + const MapLegend(), + const SizedBox(width: 16), + ], Expanded( - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final cell in row) createCell(model, cell), + for (final row in model.grid.reversed) + Expanded( + child: Row( + children: [ + for (final cell in row) createCell(model, cell), + ], + ), + ), + if (!model.isPlayingBadApple) markerControls(context, model), + const SizedBox(height: 4), + AutonomyCommandEditor(model), + const SizedBox(height: 12), ], ), ), - const SizedBox(height: 4), - if (!model.isPlayingBadApple) - Row( - children: [ - // Legend - const SizedBox(width: 4), - Text("Legend:", style: context.textTheme.titleLarge), - const SizedBox(width: 8), - Container(width: 24, height: 24, color: Colors.blue), - const SizedBox(width: 4), - Text("Rover", style: context.textTheme.titleMedium), - const SizedBox(width: 24), - Container(width: 24, height: 24, color: Colors.green), - const SizedBox(width: 4), - Text("Destination", style: context.textTheme.titleMedium), - const SizedBox(width: 24), - Container(width: 24, height: 24, color: Colors.black), - const SizedBox(width: 4), - Text("Obstacle", style: context.textTheme.titleMedium), - const SizedBox(width: 24), - Container(width: 24, height: 24, color: Colors.blueGrey), - const SizedBox(width: 4), - Text("Path", style: context.textTheme.titleMedium), - const SizedBox(width: 24), - Container(width: 24, height: 24, color: Colors.red), - const SizedBox(width: 4), - Text("Marker", style: context.textTheme.titleMedium), - const Spacer(), - Text("Zoom: ", style: context.textTheme.titleLarge), - Expanded( - flex: 2, - child: Slider( - value: model.gridSize.toDouble(), - min: 1, - max: 41, - divisions: 20, - label: "${model.gridSize}x${model.gridSize}", - onChanged: (value) => model.zoom(value.toInt()), - ), - ), - ], - ), - if (!model.isPlayingBadApple) - Row( - children: [ - // Controls - const SizedBox(width: 4), - Text("Place marker: ", style: context.textTheme.titleLarge), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text("Add Marker"), - onPressed: () => placeMarker(context, model), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.location_on), - label: const Text("Drop marker here"), - onPressed: model.placeMarkerOnRover, - ), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.clear), label: const Text("Clear all"), onPressed: model.clearMarkers), - const Spacer(), - ], - ), - const SizedBox(height: 8), - AutonomyCommandEditor(model), - const VerticalDivider(), - const SizedBox(height: 4), - ], - ), - Container( - color: context.colorScheme.surface, - height: 50, - child: Row( - children: [ - // The header at the top - const SizedBox(width: 8), - Text("Map", style: context.textTheme.headlineMedium), - if (models.settings.easterEggs.badApple) - IconButton( - iconSize: 48, - icon: CircleAvatar( - backgroundImage: const AssetImage("assets/bad_apple_thumbnail.webp"), - child: model.isPlayingBadApple ? const Icon(Icons.block, color: Colors.red, size: 36) : null, - ), - onPressed: model.isPlayingBadApple ? model.stopBadApple : model.startBadApple, - ), - const Spacer(), - ViewsSelector(index: index), + const SizedBox(width: 16), ], ), ), ], ); } + +/// Displays the legend for what each cell color represents on the map +class MapLegend extends StatelessWidget { + /// Const constructor for the map legend + const MapLegend({super.key}); + + @override + Widget build(BuildContext context) => Column( + children: [ + const SizedBox(height: 4), + Text("Legend:", style: context.textTheme.titleLarge), + const SizedBox(height: 8), + Container(width: 24, height: 24, color: Colors.blue), + const SizedBox(height: 4), + Text("Rover", style: context.textTheme.titleMedium), + const SizedBox(height: 24), + Container(width: 24, height: 24, color: Colors.green), + const SizedBox(height: 4), + Text("Destination", style: context.textTheme.titleMedium), + const SizedBox(height: 24), + Container(width: 24, height: 24, color: Colors.black), + const SizedBox(height: 4), + Text("Obstacle", style: context.textTheme.titleMedium), + const SizedBox(height: 24), + Container(width: 24, height: 24, color: Colors.blueGrey), + const SizedBox(height: 4), + Text("Path", style: context.textTheme.titleMedium), + const SizedBox(height: 24), + Container(width: 24, height: 24, color: Colors.red), + const SizedBox(height: 4), + Text("Marker", style: context.textTheme.titleMedium), + ], + ); +} + +/// The header to appear at the top of the map page, displays a button to play bad apple... +class MapPageHeader extends StatelessWidget { + /// The model to control the status of bad apple... + final AutonomyModel model; + + /// Index of the view the header is in + final int index; + + /// Const constructor for the map page header + const MapPageHeader({required this.index, required this.model, super.key}); + + @override + Widget build(BuildContext context) => Container( + color: context.colorScheme.surface, + height: 50, + child: Row( + children: [ + // The header at the top + const SizedBox(width: 8), + Text("Map", style: context.textTheme.headlineMedium), + if (models.settings.easterEggs.badApple) + IconButton( + iconSize: 48, + icon: CircleAvatar( + backgroundImage: const AssetImage("assets/bad_apple_thumbnail.webp"), + child: model.isPlayingBadApple ? const Icon(Icons.block, color: Colors.red, size: 36) : null, + ), + onPressed: model.isPlayingBadApple ? model.stopBadApple : model.startBadApple, + ), + const Spacer(), + ViewsSelector(index: index), + ], + ), + ); +} From 28e9133c73b91f5a95b978fc897039389ceab130 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Mon, 30 Sep 2024 18:28:39 -0400 Subject: [PATCH 03/30] Method and label renaming --- lib/src/models/view/map.dart | 4 ++-- lib/src/pages/map.dart | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 3dfebba97..5c7b29ace 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -195,8 +195,8 @@ class AutonomyModel with ChangeNotifier { notifyListeners(); } - /// Removes a marker in [gps] - void updateMarker(GpsCoordinates gps) { + /// Removes a marker from [gps] + void removeMarker(GpsCoordinates gps) { if (markers.remove(gps)) { notifyListeners(); } else { diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index 255b598bd..6f17533e8 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -57,7 +57,7 @@ class MapPage extends ReactiveWidget { child: GestureDetector( onTap: () { if (cell.cellType == AutonomyCell.marker) { - model.updateMarker(cell.coordinates); + model.removeMarker(cell.coordinates); } else if (cell.cellType == AutonomyCell.empty) { model.placeMarker(cell.coordinates); } @@ -86,7 +86,7 @@ class MapPage extends ReactiveWidget { children: [ // Controls const SizedBox(width: 4), - Text("Place marker: ", style: context.textTheme.titleLarge), + Text("Place Marker: ", style: context.textTheme.titleLarge), const SizedBox(width: 8), ElevatedButton.icon( icon: const Icon(Icons.add), @@ -96,13 +96,13 @@ class MapPage extends ReactiveWidget { const SizedBox(width: 8), ElevatedButton.icon( icon: const Icon(Icons.location_on), - label: const Text("Drop marker here"), + label: const Text("Drop Marker Here"), onPressed: model.placeMarkerOnRover, ), const SizedBox(width: 8), ElevatedButton.icon( icon: const Icon(Icons.clear), - label: const Text("Clear all"), + label: const Text("Clear All"), onPressed: model.clearMarkers, ), const Spacer(), From e91fc655f38125996470fb33fd7dc9929d2592e8 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Mon, 30 Sep 2024 18:54:25 -0400 Subject: [PATCH 04/30] Fix bad apple drawing after stopping --- lib/src/models/view/map.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 5c7b29ace..bf38b8e39 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -271,6 +271,9 @@ class AutonomyModel with ChangeNotifier { if (isBlack) obstacles.add(coordinate); } } + if (!isPlayingBadApple) { + return; + } data = AutonomyData(obstacles: obstacles); notifyListeners(); badAppleFrame += badAppleFps; From b2c2b2e0bfabfa249c532cd5c6cf9318bb646734 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:38:23 -0500 Subject: [PATCH 05/30] Made map widget square --- lib/src/pages/map.dart | 198 +++++++++++-------- lib/src/widgets/atomic/autonomy_command.dart | 62 +++--- 2 files changed, 154 insertions(+), 106 deletions(-) diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index 6f17533e8..91f2110f0 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -19,7 +19,8 @@ class MapPage extends ReactiveWidget { }; /// Opens a dialog to prompt the user for GPS coordinates and places a marker there. - void placeMarker(BuildContext context, AutonomyModel model) => showDialog( + void placeMarker(BuildContext context, AutonomyModel model) => + showDialog( context: context, builder: (_) => AlertDialog( title: const Text("Add a Marker"), @@ -28,7 +29,9 @@ class MapPage extends ReactiveWidget { children: [GpsEditor(model.markerBuilder)], ), actions: [ - TextButton(child: const Text("Cancel"), onPressed: () => Navigator.of(context).pop()), + TextButton( + child: const Text("Cancel"), + onPressed: () => Navigator.of(context).pop()), ElevatedButton( onPressed: model.markerBuilder.isValid ? () { @@ -64,7 +67,10 @@ class MapPage extends ReactiveWidget { }, child: Container( width: 24, - decoration: BoxDecoration(color: getColor(cell.cellType), border: Border.all()), + decoration: BoxDecoration( + color: getColor(cell.cellType), + border: Border.all(), + ), child: cell.cellType != AutonomyCell.rover ? null : Container( @@ -82,82 +88,100 @@ class MapPage extends ReactiveWidget { ); /// The controls for creating, placing, and removing markers - Widget markerControls(BuildContext context, AutonomyModel model) => Row( + Widget markerControls(BuildContext context, AutonomyModel model) => Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ // Controls - const SizedBox(width: 4), Text("Place Marker: ", style: context.textTheme.titleLarge), - const SizedBox(width: 8), + const SizedBox(height: 8), ElevatedButton.icon( icon: const Icon(Icons.add), label: const Text("Add Marker"), onPressed: () => placeMarker(context, model), ), - const SizedBox(width: 8), + const SizedBox(height: 8), ElevatedButton.icon( icon: const Icon(Icons.location_on), label: const Text("Drop Marker Here"), onPressed: model.placeMarkerOnRover, ), - const SizedBox(width: 8), + const SizedBox(height: 8), ElevatedButton.icon( icon: const Icon(Icons.clear), label: const Text("Clear All"), onPressed: model.clearMarkers, ), - const Spacer(), - Text("Zoom: ", style: context.textTheme.titleLarge), - Expanded( - flex: 2, - child: Slider( - value: model.gridSize.toDouble(), - min: 1, - max: 41, - divisions: 20, - label: "${model.gridSize}x${model.gridSize}", - onChanged: (value) => model.zoom(value.toInt()), - ), - ), ], ); @override - Widget build(BuildContext context, AutonomyModel model) => Column( - children: [ - MapPageHeader(model: model, index: index), - const SizedBox(height: 24), - Expanded( - child: Row( - children: [ - if (!model.isPlayingBadApple) ...[ - const SizedBox(width: 16), - const MapLegend(), - const SizedBox(width: 16), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final row in model.grid.reversed) - Expanded( - child: Row( - children: [ - for (final cell in row) createCell(model, cell), - ], + Widget build(BuildContext context, AutonomyModel model) => LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + MapPageHeader(model: model, index: index), + Expanded( + child: Row( + children: [ + if (constraints.maxWidth > 700) ...[ + const SizedBox(width: 16), + const MapLegend(), + const SizedBox(width: 16), + ], + const Spacer(), + AspectRatio( + aspectRatio: 1, + child: Column( + children: [ + for (final row in model.grid.reversed) + Expanded( + child: Row( + children: [ + for (final cell in row) createCell(model, cell), + ], + ), ), + ], + ), + ), + const SizedBox(width: 16), + const Spacer(), + Flexible( + flex: 3, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + markerControls(context, model), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Zoom:", style: context.textTheme.titleLarge), + AbsorbPointer( + absorbing: model.isPlayingBadApple, + child: Slider( + value: model.gridSize.clamp(1, 41).toDouble(), + min: 1, + max: 41, + divisions: 20, + label: "${model.gridSize}x${model.gridSize}", + onChanged: (value) => model.zoom(value.toInt()), + ), + ), + ], ), - if (!model.isPlayingBadApple) markerControls(context, model), - const SizedBox(height: 4), - AutonomyCommandEditor(model), - const SizedBox(height: 12), - ], + // const SizedBox(height: 4), + AutonomyCommandEditor(model), + // const SizedBox(height: 12), + ], + ), ), - ), - const SizedBox(width: 16), - ], + const Spacer(), + // const SizedBox(width: 32), + ], + ), ), - ), - ], + ], + ), ); } @@ -168,29 +192,44 @@ class MapLegend extends StatelessWidget { @override Widget build(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - const SizedBox(height: 4), Text("Legend:", style: context.textTheme.titleLarge), - const SizedBox(height: 8), - Container(width: 24, height: 24, color: Colors.blue), - const SizedBox(height: 4), - Text("Rover", style: context.textTheme.titleMedium), - const SizedBox(height: 24), - Container(width: 24, height: 24, color: Colors.green), - const SizedBox(height: 4), - Text("Destination", style: context.textTheme.titleMedium), - const SizedBox(height: 24), - Container(width: 24, height: 24, color: Colors.black), - const SizedBox(height: 4), - Text("Obstacle", style: context.textTheme.titleMedium), - const SizedBox(height: 24), - Container(width: 24, height: 24, color: Colors.blueGrey), - const SizedBox(height: 4), - Text("Path", style: context.textTheme.titleMedium), - const SizedBox(height: 24), - Container(width: 24, height: 24, color: Colors.red), - const SizedBox(height: 4), - Text("Marker", style: context.textTheme.titleMedium), + Column( + children: [ + Container(width: 24, height: 24, color: Colors.blue), + const SizedBox(height: 2), + Text("Rover", style: context.textTheme.titleMedium), + ], + ), + Column( + children: [ + Container(width: 24, height: 24, color: Colors.green), + const SizedBox(height: 2), + Text("Destination", style: context.textTheme.titleMedium), + ], + ), + Column( + children: [ + Container(width: 24, height: 24, color: Colors.black), + const SizedBox(height: 2), + Text("Obstacle", style: context.textTheme.titleMedium), + ], + ), + Column( + children: [ + Container(width: 24, height: 24, color: Colors.blueGrey), + const SizedBox(height: 2), + Text("Path", style: context.textTheme.titleMedium), + ], + ), + Column( + children: [ + Container(width: 24, height: 24, color: Colors.red), + const SizedBox(height: 2), + Text("Marker", style: context.textTheme.titleMedium), + ], + ), ], ); } @@ -219,10 +258,15 @@ class MapPageHeader extends StatelessWidget { IconButton( iconSize: 48, icon: CircleAvatar( - backgroundImage: const AssetImage("assets/bad_apple_thumbnail.webp"), - child: model.isPlayingBadApple ? const Icon(Icons.block, color: Colors.red, size: 36) : null, + backgroundImage: + const AssetImage("assets/bad_apple_thumbnail.webp"), + child: model.isPlayingBadApple + ? const Icon(Icons.block, color: Colors.red, size: 36) + : null, ), - onPressed: model.isPlayingBadApple ? model.stopBadApple : model.startBadApple, + onPressed: model.isPlayingBadApple + ? model.stopBadApple + : model.startBadApple, ), const Spacer(), ViewsSelector(index: index), diff --git a/lib/src/widgets/atomic/autonomy_command.dart b/lib/src/widgets/atomic/autonomy_command.dart index aabece948..cadbe81e1 100644 --- a/lib/src/widgets/atomic/autonomy_command.dart +++ b/lib/src/widgets/atomic/autonomy_command.dart @@ -46,33 +46,37 @@ class AutonomyCommandEditor extends ReactiveWidget { ); @override - Widget build(BuildContext context, AutonomyCommandBuilder model) => Row(mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(width: 4), - Text("Autonomy: ", style: context.textTheme.titleLarge), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text("New Task"), - onPressed: () { - if (RoverStatus.AUTONOMOUS == models.rover.status.value) { - createTask(context, model); - } else { - models.home.setMessage( - severity: Severity.error, - text: "You must be in autonomy mode to do that", - ); - } - }, - ), - const SizedBox(width: 8), - ElevatedButton( - style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.red)), - onPressed: model.abort, - child: const Text("ABORT"), - ), - const Spacer(), - if (!dataModel.isPlayingBadApple) - Text("${dataModel.data.state.humanName}, ${dataModel.data.task.humanName}", style: context.textTheme.titleLarge), - const SizedBox(width: 8), - ],); + Widget build(BuildContext context, AutonomyCommandBuilder model) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Autonomy: ", style: context.textTheme.titleLarge), + Text( + "${dataModel.data.state.humanName}, ${dataModel.data.task.humanName}", + style: context.textTheme.titleLarge, + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text("New Task"), + onPressed: () { + if (RoverStatus.AUTONOMOUS == models.rover.status.value) { + createTask(context, model); + } else { + models.home.setMessage( + severity: Severity.error, + text: "You must be in autonomy mode to do that", + ); + } + }, + ), + const SizedBox(height: 8), + ElevatedButton( + style: const ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.red), + ), + onPressed: model.abort, + child: const Text("ABORT"), + ), + ], + ); } From 1bb55fb9e76d912adf5ac2e97f01a74ec209195e Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 5 Nov 2024 00:16:11 -0500 Subject: [PATCH 06/30] Added drag and drop for planning tasks --- lib/src/models/view/map.dart | 8 +- lib/src/pages/map.dart | 157 +++++++++++++------ lib/src/widgets/atomic/autonomy_command.dart | 7 +- 3 files changed, 119 insertions(+), 53 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index bf38b8e39..1a5211745 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -119,9 +119,11 @@ class AutonomyModel with ChangeNotifier { markCell(result, obstacle, AutonomyCell.obstacle); } if (isPlayingBadApple) return result; - for (final path in data.path) { - markCell(result, path, AutonomyCell.path); - } + for (final path in data.path) { + if (!data.obstacles.contains(path)) { + markCell(result, path, AutonomyCell.path); + } + } for (final marker in markers) { markCell(result, marker, AutonomyCell.marker); } diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index 91f2110f0..bfd29585b 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -1,4 +1,5 @@ import "dart:math"; +import "package:burt_network/generated.dart"; import "package:flutter/material.dart"; import "package:rover_dashboard/models.dart"; @@ -30,8 +31,9 @@ class MapPage extends ReactiveWidget { ), actions: [ TextButton( - child: const Text("Cancel"), - onPressed: () => Navigator.of(context).pop()), + child: const Text("Cancel"), + onPressed: () => Navigator.of(context).pop(), + ), ElevatedButton( onPressed: model.markerBuilder.isValid ? () { @@ -49,40 +51,75 @@ class MapPage extends ReactiveWidget { /// The index of this view. final int index; + /// Builder for autonomy commands + final AutonomyCommandBuilder commandBuilder = AutonomyCommandBuilder(); + /// A const constructor. - const MapPage({required this.index}); + MapPage({required this.index}); @override AutonomyModel createModel() => AutonomyModel(); /// Creates a widget to display the cell data for the provided [cell] Widget createCell(AutonomyModel model, MapCellData cell) => Expanded( - child: GestureDetector( - onTap: () { - if (cell.cellType == AutonomyCell.marker) { - model.removeMarker(cell.coordinates); - } else if (cell.cellType == AutonomyCell.empty) { - model.placeMarker(cell.coordinates); + child: DragTarget( + onAcceptWithDetails: (details) { + switch (details.data) { + case AutonomyCell.destination: + { + commandBuilder.gps.latDecimal.value = cell.coordinates.latitude; + commandBuilder.gps.longDecimal.value = cell.coordinates.longitude; + + commandBuilder.submit(); + break; + } + case AutonomyCell.obstacle: + { + final obstacleData = AutonomyData(obstacles: [cell.coordinates]); + models.sockets.autonomy.sendMessage(obstacleData); + break; + } + case AutonomyCell.marker: + { + if (cell.cellType == AutonomyCell.marker) { + model.removeMarker(cell.coordinates); + } else if (cell.cellType == AutonomyCell.empty) { + model.placeMarker(cell.coordinates); + } + break; + } + // ignore: no_default_cases + default: + break; } }, - child: Container( - width: 24, - decoration: BoxDecoration( - color: getColor(cell.cellType), - border: Border.all(), - ), - child: cell.cellType != AutonomyCell.rover - ? null - : Container( - color: Colors.blue, - width: double.infinity, - height: double.infinity, - margin: const EdgeInsets.all(4), - child: Transform.rotate( - angle: -model.roverHeading * pi / 180, - child: const Icon(Icons.arrow_upward, size: 24), + builder: (context, candidates, rejects) => GestureDetector( + onTap: () { + if (cell.cellType == AutonomyCell.marker) { + model.removeMarker(cell.coordinates); + } else if (cell.cellType == AutonomyCell.empty) { + model.placeMarker(cell.coordinates); + } + }, + child: Container( + width: 24, + decoration: BoxDecoration( + color: getColor(cell.cellType), + border: Border.all(), + ), + child: cell.cellType != AutonomyCell.rover + ? null + : Container( + color: Colors.blue, + width: double.infinity, + height: double.infinity, + margin: const EdgeInsets.all(4), + child: Transform.rotate( + angle: -model.roverHeading * pi / 180, + child: const Icon(Icons.arrow_upward, size: 24), + ), ), - ), + ), ), ), ); @@ -170,7 +207,7 @@ class MapPage extends ReactiveWidget { ], ), // const SizedBox(height: 4), - AutonomyCommandEditor(model), + AutonomyCommandEditor(commandBuilder, model), // const SizedBox(height: 12), ], ), @@ -202,19 +239,39 @@ class MapLegend extends StatelessWidget { Text("Rover", style: context.textTheme.titleMedium), ], ), - Column( - children: [ - Container(width: 24, height: 24, color: Colors.green), - const SizedBox(height: 2), - Text("Destination", style: context.textTheme.titleMedium), - ], + Draggable( + data: AutonomyCell.destination, + dragAnchorStrategy: (draggable, context, position) => + const Offset(12, 12), + feedback: Container( + width: 24, + height: 24, + color: Colors.green.withOpacity(0.50), + ), + child: Column( + children: [ + Container(width: 24, height: 24, color: Colors.green), + const SizedBox(height: 2), + Text("Destination", style: context.textTheme.titleMedium), + ], + ), ), - Column( - children: [ - Container(width: 24, height: 24, color: Colors.black), - const SizedBox(height: 2), - Text("Obstacle", style: context.textTheme.titleMedium), - ], + Draggable( + data: AutonomyCell.obstacle, + dragAnchorStrategy: (draggable, context, position) => + const Offset(12, 12), + feedback: Container( + width: 24, + height: 24, + color: Colors.black.withOpacity(0.50), + ), + child: Column( + children: [ + Container(width: 24, height: 24, color: Colors.black), + const SizedBox(height: 2), + Text("Obstacle", style: context.textTheme.titleMedium), + ], + ), ), Column( children: [ @@ -223,12 +280,22 @@ class MapLegend extends StatelessWidget { Text("Path", style: context.textTheme.titleMedium), ], ), - Column( - children: [ - Container(width: 24, height: 24, color: Colors.red), - const SizedBox(height: 2), - Text("Marker", style: context.textTheme.titleMedium), - ], + Draggable( + data: AutonomyCell.marker, + dragAnchorStrategy: (draggable, context, position) => + const Offset(12, 12), + feedback: Container( + width: 24, + height: 24, + color: Colors.red.withOpacity(0.50), + ), + child: Column( + children: [ + Container(width: 24, height: 24, color: Colors.red), + const SizedBox(height: 2), + Text("Marker", style: context.textTheme.titleMedium), + ], + ), ), ], ); diff --git a/lib/src/widgets/atomic/autonomy_command.dart b/lib/src/widgets/atomic/autonomy_command.dart index cadbe81e1..f4c2ee2a8 100644 --- a/lib/src/widgets/atomic/autonomy_command.dart +++ b/lib/src/widgets/atomic/autonomy_command.dart @@ -5,14 +5,11 @@ import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/widgets.dart"; /// A widget to edit an [AutonomyCommand]. -class AutonomyCommandEditor extends ReactiveWidget { +class AutonomyCommandEditor extends ReusableReactiveWidget { /// The autonomy view model. final AutonomyModel dataModel; /// A const constructor. - const AutonomyCommandEditor(this.dataModel); - - @override - AutonomyCommandBuilder createModel() => AutonomyCommandBuilder(); + const AutonomyCommandEditor(super.model, this.dataModel); /// Opens a dialog to prompt the user to create an [AutonomyCommand] and sends it to the rover. void createTask(BuildContext context, AutonomyCommandBuilder command) => showDialog( From a531fb339702a450e66864bb46678d500ad73faa Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 5 Nov 2024 09:09:16 -0500 Subject: [PATCH 07/30] Adjusted opacity of drag and drop --- lib/src/pages/map.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index bfd29585b..91841251b 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -246,7 +246,7 @@ class MapLegend extends StatelessWidget { feedback: Container( width: 24, height: 24, - color: Colors.green.withOpacity(0.50), + color: Colors.green.withOpacity(0.75), ), child: Column( children: [ @@ -263,7 +263,7 @@ class MapLegend extends StatelessWidget { feedback: Container( width: 24, height: 24, - color: Colors.black.withOpacity(0.50), + color: Colors.black.withOpacity(0.75), ), child: Column( children: [ @@ -287,7 +287,7 @@ class MapLegend extends StatelessWidget { feedback: Container( width: 24, height: 24, - color: Colors.red.withOpacity(0.50), + color: Colors.red.withOpacity(0.75), ), child: Column( children: [ From 077a12954b797d06343c835151ff171d9f3f2d18 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 5 Nov 2024 09:11:44 -0500 Subject: [PATCH 08/30] Raised minimum width needed for legend --- lib/src/pages/map.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index 91841251b..51ae50c5a 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -67,15 +67,18 @@ class MapPage extends ReactiveWidget { switch (details.data) { case AutonomyCell.destination: { - commandBuilder.gps.latDecimal.value = cell.coordinates.latitude; - commandBuilder.gps.longDecimal.value = cell.coordinates.longitude; + commandBuilder.gps.latDecimal.value = + cell.coordinates.latitude; + commandBuilder.gps.longDecimal.value = + cell.coordinates.longitude; commandBuilder.submit(); break; } case AutonomyCell.obstacle: { - final obstacleData = AutonomyData(obstacles: [cell.coordinates]); + final obstacleData = + AutonomyData(obstacles: [cell.coordinates]); models.sockets.autonomy.sendMessage(obstacleData); break; } @@ -159,7 +162,7 @@ class MapPage extends ReactiveWidget { Expanded( child: Row( children: [ - if (constraints.maxWidth > 700) ...[ + if (constraints.maxWidth > 880) ...[ const SizedBox(width: 16), const MapLegend(), const SizedBox(width: 16), @@ -183,7 +186,7 @@ class MapPage extends ReactiveWidget { const SizedBox(width: 16), const Spacer(), Flexible( - flex: 3, + flex: 4, child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.start, From 9cd6a826f8e8449338bf7a797b70345587d49d3f Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sat, 9 Nov 2024 18:48:22 -0500 Subject: [PATCH 09/30] A bunch of improvements - Shrunk autonomy status message - Allow map to shrink size - Scale robot arrow and border based on size - Draw markers over path - Fix the "add marker here" button - Display the underlying cell type below the rover --- lib/src/models/view/map.dart | 28 ++++++- lib/src/pages/map.dart | 80 +++++++++++--------- lib/src/widgets/atomic/autonomy_command.dart | 4 +- 3 files changed, 73 insertions(+), 39 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 1a5211745..57b0685a9 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -38,6 +38,15 @@ class GridOffset { const GridOffset(this.x, this.y); } +extension _GpsCoordinatesToBlock on GpsCoordinates { + GpsCoordinates get toGridBlock => GpsCoordinates( + latitude: + (latitude / models.settings.dashboard.mapBlockSize).roundToDouble(), + longitude: + (longitude / models.settings.dashboard.mapBlockSize).roundToDouble(), + ); +} + /// A record representing data necessary to display a cell in the map typedef MapCellData = ({GpsCoordinates coordinates, AutonomyCell cellType}); @@ -105,6 +114,21 @@ class AutonomyModel with ChangeNotifier { /// The rover's current position. GpsCoordinates get roverPosition => models.rover.metrics.position.data.gps; + + /// The cell type of the rover that isn't [AutonomyCell.rover] + AutonomyCell get roverCellType { + final roverCoordinates = roverPosition..toGridBlock; + + if (data.hasDestination() && data.destination == roverCoordinates) { + return AutonomyCell.destination; + } else if (markers.contains(roverCoordinates)) { + return AutonomyCell.marker; + } else if (data.path.contains(roverCoordinates)) { + return AutonomyCell.path; + } + + return AutonomyCell.empty; + } /// The rover's heading double get roverHeading => models.rover.metrics.position.angle; @@ -193,7 +217,9 @@ class AutonomyModel with ChangeNotifier { /// Places a marker at the rover's current position. void placeMarkerOnRover() { - markers.add(roverPosition); + if (!markers.contains(roverPosition.toGridBlock)) { + markers.add(roverPosition.toGridBlock); + } notifyListeners(); } diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index 51ae50c5a..8ae4a41cb 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -10,13 +10,13 @@ import "package:rover_dashboard/widgets.dart"; /// Displays a bird's-eye view of the rover and its path to the goal. class MapPage extends ReactiveWidget { /// Gets the color for a given [AutonomyCell]. - Color? getColor(AutonomyCell cell) => switch (cell) { + Color? getColor(AutonomyCell cell, AutonomyModel model) => switch (cell) { AutonomyCell.destination => Colors.green, AutonomyCell.obstacle => Colors.black, AutonomyCell.path => Colors.blueGrey, AutonomyCell.empty => Colors.white, AutonomyCell.marker => Colors.red, - AutonomyCell.rover => Colors.transparent, + AutonomyCell.rover => getColor(model.roverCellType, model), }; /// Opens a dialog to prompt the user for GPS coordinates and places a marker there. @@ -84,9 +84,9 @@ class MapPage extends ReactiveWidget { } case AutonomyCell.marker: { - if (cell.cellType == AutonomyCell.marker) { + if (model.markers.contains(cell.coordinates)) { model.removeMarker(cell.coordinates); - } else if (cell.cellType == AutonomyCell.empty) { + } else { model.placeMarker(cell.coordinates); } break; @@ -98,28 +98,33 @@ class MapPage extends ReactiveWidget { }, builder: (context, candidates, rejects) => GestureDetector( onTap: () { - if (cell.cellType == AutonomyCell.marker) { + if (model.markers.contains(cell.coordinates)) { model.removeMarker(cell.coordinates); - } else if (cell.cellType == AutonomyCell.empty) { + } else { model.placeMarker(cell.coordinates); } }, child: Container( width: 24, decoration: BoxDecoration( - color: getColor(cell.cellType), + color: getColor(cell.cellType, model), border: Border.all(), ), child: cell.cellType != AutonomyCell.rover ? null - : Container( - color: Colors.blue, - width: double.infinity, - height: double.infinity, - margin: const EdgeInsets.all(4), - child: Transform.rotate( - angle: -model.roverHeading * pi / 180, - child: const Icon(Icons.arrow_upward, size: 24), + : LayoutBuilder( + builder: (context, constraints) => Container( + color: Colors.blue, + width: double.infinity, + height: double.infinity, + margin: EdgeInsets.all(constraints.maxWidth / 15), + child: Transform.rotate( + angle: -model.roverHeading * pi / 180, + child: Icon( + Icons.arrow_upward, + size: constraints.maxWidth * 24 / 30, + ), + ), ), ), ), @@ -161,32 +166,38 @@ class MapPage extends ReactiveWidget { MapPageHeader(model: model, index: index), Expanded( child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (constraints.maxWidth > 880) ...[ + if (constraints.maxWidth > 700) ...[ const SizedBox(width: 16), const MapLegend(), - const SizedBox(width: 16), ], - const Spacer(), - AspectRatio( - aspectRatio: 1, - child: Column( - children: [ - for (final row in model.grid.reversed) - Expanded( - child: Row( - children: [ - for (final cell in row) createCell(model, cell), - ], + const SizedBox(width: 16), + Flexible( + flex: 10, + child: AspectRatio( + aspectRatio: 1, + child: Column( + children: [ + for (final row in model.grid.reversed) + Expanded( + child: Row( + children: [ + for (final cell in row) + createCell(model, cell), + ], + ), ), - ), - ], + ], + ), ), ), const SizedBox(width: 16), const Spacer(), - Flexible( - flex: 4, + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 250, + ), child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.start, @@ -209,14 +220,11 @@ class MapPage extends ReactiveWidget { ), ], ), - // const SizedBox(height: 4), AutonomyCommandEditor(commandBuilder, model), - // const SizedBox(height: 12), ], ), ), - const Spacer(), - // const SizedBox(width: 32), + const SizedBox(width: 16), ], ), ), diff --git a/lib/src/widgets/atomic/autonomy_command.dart b/lib/src/widgets/atomic/autonomy_command.dart index f4c2ee2a8..a53c918c1 100644 --- a/lib/src/widgets/atomic/autonomy_command.dart +++ b/lib/src/widgets/atomic/autonomy_command.dart @@ -46,10 +46,10 @@ class AutonomyCommandEditor extends ReusableReactiveWidget Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("Autonomy: ", style: context.textTheme.titleLarge), + Text("Autonomy:", style: context.textTheme.titleLarge), Text( "${dataModel.data.state.humanName}, ${dataModel.data.task.humanName}", - style: context.textTheme.titleLarge, + style: context.textTheme.titleMedium, ), const SizedBox(height: 8), ElevatedButton.icon( From 5653d7c82059471d181355f02d8179cc66771446 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sat, 9 Nov 2024 18:57:06 -0500 Subject: [PATCH 10/30] Don't display markers over obstacles --- lib/src/models/view/map.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 57b0685a9..74bc33637 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -149,7 +149,9 @@ class AutonomyModel with ChangeNotifier { } } for (final marker in markers) { - markCell(result, marker, AutonomyCell.marker); + if (!data.obstacles.contains(marker)) { + markCell(result, marker, AutonomyCell.marker); + } } // Marks the rover and destination -- these should be last if (data.hasDestination()) markCell(result, data.destination, AutonomyCell.destination); From 7a6011de497f7f771df4dbd44216943380c3399f Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sat, 9 Nov 2024 20:20:03 -0500 Subject: [PATCH 11/30] Fix everything being wrong with different grid sizes --- lib/src/models/view/map.dart | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 74bc33637..5395d2881 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -100,12 +100,21 @@ class AutonomyModel with ChangeNotifier { } /// An empty grid of size [gridSize]. - AutonomyGrid get empty => [ - for (int i = 0; i < gridSize; i++) [ - for (int j = 0; j < gridSize; j++) - (coordinates: GpsCoordinates(longitude: -j.toDouble() + offset.x, latitude: i.toDouble() - offset.y), cellType: AutonomyCell.empty), - ], - ]; + AutonomyGrid get empty => [ + for (int i = 0; i < gridSize; i++) + [ + for (int j = 0; j < gridSize; j++) + ( + coordinates: GpsCoordinates( + longitude: (-j.toDouble() + offset.x) * + models.settings.dashboard.mapBlockSize, + latitude: (i.toDouble() - offset.y) * + models.settings.dashboard.mapBlockSize, + ), + cellType: AutonomyCell.empty + ), + ], + ]; /// A list of markers manually placed by the user. Useful for the Extreme Retrieval Mission. List markers = []; From b31b5432d59e4ab0e82e4e6bf5b241a76410581e Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sun, 10 Nov 2024 16:39:09 -0500 Subject: [PATCH 12/30] Improved bad apple - audio works - video is synced with audio --- lib/src/models/view/map.dart | 62 ++++++++++++------- pubspec.lock | 12 +++- pubspec.yaml | 3 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 5 files changed, 57 insertions(+), 24 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 5395d2881..28afc3b51 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -257,8 +257,6 @@ class AutonomyModel with ChangeNotifier { /// Whether the UI is currently playing Bad Apple bool isPlayingBadApple = false; - /// A timer to update the map for every frame of Bad Apple - Timer? badAppleTimer; /// Which frame in the Bad Apple video we are up to right now int badAppleFrame = 0; /// The audio player for the Bad Apple music @@ -267,6 +265,8 @@ class AutonomyModel with ChangeNotifier { static const badAppleFps = 1; /// The last frame of Bad Apple static const badAppleLastFrame = 6570; + /// A stopwatch to track the current time in the bad apple video + final Stopwatch _badAppleStopwatch = Stopwatch(); /// Starts playing Bad Apple. Future startBadApple() async { @@ -274,14 +274,36 @@ class AutonomyModel with ChangeNotifier { notifyListeners(); zoom(50); badAppleFrame = 0; - badAppleTimer = Timer.periodic(const Duration(milliseconds: 1000 ~/ 30), _loadBadAppleFrame); - await badAppleAudioPlayer.setAsset("assets/bad_apple2.mp3"); - badAppleAudioPlayer.play().ignore(); + Timer.run(() async { + await badAppleAudioPlayer.setAsset("assets/bad_apple2.mp3"); + _badAppleStopwatch.start(); + badAppleAudioPlayer.play().ignore(); + }); + + while (isPlayingBadApple) { + await Future.delayed(Duration.zero); + badAppleFrame = + ((_badAppleStopwatch.elapsedMicroseconds.toDouble() / 1e6) * 30.0) + .round(); + if (badAppleFrame >= badAppleLastFrame) { + stopBadApple(); + break; + } + final obstacles = await _loadBadAppleFrame(badAppleFrame); + if (obstacles == null) { + continue; + } + if (!isPlayingBadApple) { + break; + } + data = AutonomyData(obstacles: obstacles); + notifyListeners(); + } } - Future _loadBadAppleFrame(_) async { + Future?> _loadBadAppleFrame(int videoFrame) async { // final filename = "assets/bad_apple/image_480.jpg"; - final filename = "assets/bad_apple/image_$badAppleFrame.jpg"; + final filename = "assets/bad_apple/image_$videoFrame.jpg"; final buffer = await rootBundle.loadBuffer(filename); final codec = await instantiateImageCodecWithSize(buffer); final frame = await codec.getNextFrame(); @@ -289,42 +311,40 @@ class AutonomyModel with ChangeNotifier { if (image.height != 50 || image.width != 50) { models.home.setMessage(severity: Severity.error, text: "Wrong Bad Apple frame size"); stopBadApple(); - return; + return null; } final imageData = await image.toByteData(); if (imageData == null) { models.home.setMessage(severity: Severity.error, text: "Could not load Bad Apple frame"); stopBadApple(); - return; + return null; } var offset = 0; final obstacles = []; for (var row = 0; row < image.height; row++) { for (var col = 0; col < image.width; col++) { - final pixel = [ - for (var channel = 0; channel < 4; channel++) - imageData.getUint8(offset++), - ]; - final isBlack = pixel.first < 100; // dealing with lossy compression, not 255 and 0 - final coordinate = GpsCoordinates(latitude: (row - image.height).abs().toDouble(), longitude: col.toDouble()); + final pixel = imageData.getUint8(offset++); + imageData.getUint8(offset++); + imageData.getUint8(offset++); + imageData.getUint8(offset++); + final isBlack = pixel < 100; // dealing with lossy compression, not 255 and 0 + final coordinate = GpsCoordinates(latitude: (row - image.height).abs().toDouble(), longitude: (image.width - col - 1).abs().toDouble()); if (isBlack) obstacles.add(coordinate); } } if (!isPlayingBadApple) { - return; + return null; } - data = AutonomyData(obstacles: obstacles); - notifyListeners(); - badAppleFrame += badAppleFps; - if (badAppleFrame >= badAppleLastFrame) stopBadApple(); + return obstacles; } /// Stops playing Bad Apple and resets the UI. void stopBadApple() { isPlayingBadApple = false; - badAppleTimer?.cancel(); data = AutonomyData(); badAppleAudioPlayer.stop(); + _badAppleStopwatch.stop(); + _badAppleStopwatch.reset(); zoom(11); notifyListeners(); } diff --git a/pubspec.lock b/pubspec.lock index 82d8cfa24..d508c84be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -278,10 +278,10 @@ packages: dependency: "direct main" description: name: just_audio - sha256: b41646a8241688f1d99c2e69c4da2bb26aa4b3a99795f6ff205c2a165e033fda + sha256: a49e7120b95600bd357f37a2bb04cd1e88252f7cdea8f3368803779b925b1049 url: "https://pub.dev" source: hosted - version: "0.9.41" + version: "0.9.42" just_audio_platform_interface: dependency: transitive description: @@ -298,6 +298,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.13" + just_audio_windows: + dependency: "direct main" + description: + name: just_audio_windows + sha256: b1ba5305d841c0e3883644e20fc11aaa23f28cfdd43ec20236d1e119a402ef29 + url: "https://pub.dev" + source: hosted + version: "0.2.2" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d838d417d..eccf1b004 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,9 +20,10 @@ dependencies: path_provider: ^2.0.14 protobuf: ^3.0.0 url_launcher: ^6.1.10 - just_audio: ^0.9.36 + just_audio: ^0.9.42 collection: ^1.18.0 flutter_sdl_gamepad: ^1.0.0 + just_audio_windows: ^0.2.2 dependency_overrides: flutter_libserialport: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index f15090eeb..59a4548e1 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterLibserialportPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterLibserialportPlugin")); + JustAudioWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("JustAudioWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9cab37959..6c76dbda1 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_libserialport + just_audio_windows url_launcher_windows ) From e601ac9e8c88e4b6f1a91f9798e649197fc515fe Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sun, 10 Nov 2024 18:00:17 -0500 Subject: [PATCH 13/30] Use custom paint for bad apple --- lib/src/pages/map.dart | 148 +++++++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 28 deletions(-) diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index 8ae4a41cb..5500993b2 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -177,19 +177,26 @@ class MapPage extends ReactiveWidget { flex: 10, child: AspectRatio( aspectRatio: 1, - child: Column( - children: [ - for (final row in model.grid.reversed) - Expanded( - child: Row( - children: [ - for (final cell in row) - createCell(model, cell), - ], + child: (!model.isPlayingBadApple) + ? Column( + children: [ + for (final row in model.grid.reversed) + Expanded( + child: Row( + children: [ + for (final cell in row) + createCell(model, cell), + ], + ), + ), + ], + ) + : CustomPaint( + painter: _BadApplePainter( + frameNumber: model.badAppleFrame, + obstacles: model.data.obstacles, ), ), - ], - ), ), ), const SizedBox(width: 16), @@ -198,18 +205,21 @@ class MapPage extends ReactiveWidget { constraints: const BoxConstraints( maxWidth: 250, ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - markerControls(context, model), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Zoom:", style: context.textTheme.titleLarge), - AbsorbPointer( - absorbing: model.isPlayingBadApple, - child: Slider( + child: AbsorbPointer( + absorbing: model.isPlayingBadApple, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + markerControls(context, model), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Zoom:", + style: context.textTheme.titleLarge, + ), + Slider( value: model.gridSize.clamp(1, 41).toDouble(), min: 1, max: 41, @@ -217,11 +227,11 @@ class MapPage extends ReactiveWidget { label: "${model.gridSize}x${model.gridSize}", onChanged: (value) => model.zoom(value.toInt()), ), - ), - ], - ), - AutonomyCommandEditor(commandBuilder, model), - ], + ], + ), + AutonomyCommandEditor(commandBuilder, model), + ], + ), ), ), const SizedBox(width: 16), @@ -352,3 +362,85 @@ class MapPageHeader extends StatelessWidget { ), ); } + +class _BadApplePainter extends CustomPainter { + final int frameNumber; + final List obstacles; + + _BadApplePainter({required this.frameNumber, required this.obstacles}); + + @override + void paint(Canvas canvas, Size size) { + canvas.drawRect( + Rect.fromLTRB(0, 0, size.width, size.height), + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill, + ); + drawGrid(canvas, size); + drawPixels(canvas, size); + } + + void drawGrid(Canvas canvas, Size size) { + final linePaint = Paint() + ..color = Colors.black + ..strokeWidth = 2 + ..strokeCap = StrokeCap.butt; + + final borderPaint = Paint() + ..color = Colors.black + ..strokeWidth = 1 + ..strokeCap = StrokeCap.square + ..style = PaintingStyle.stroke; + + // Columns + for (var i = 1; i <= 49; i++) { + canvas.drawLine( + Offset(i * size.width / 50, 0), + Offset(i * size.width / 50, size.height), + linePaint, + ); + } + + // Rows + for (var i = 1; i <= 49; i++) { + canvas.drawLine( + Offset(0, i * size.height / 50), + Offset(size.width, i * size.height / 50), + linePaint, + ); + } + + canvas.drawRect( + Rect.fromCenter( + center: Offset(size.width, size.height) / 2, + width: size.width - 1, + height: size.height - 1, + ), + borderPaint, + ); + } + + void drawPixels(Canvas canvas, Size size) { + final boxWidth = size.width / 50; + final boxHeight = size.height / 50; + + final paint = Paint() + ..color = Colors.black + ..style = PaintingStyle.fill; + + for (final coordinates in obstacles) { + final rect = Rect.fromLTWH( + size.width - (coordinates.longitude + 1) * boxHeight, + size.height - coordinates.latitude * boxWidth, + boxWidth, + boxHeight, + ); + canvas.drawRect(rect, paint); + } + } + + @override + bool shouldRepaint(_BadApplePainter oldDelegate) => + frameNumber != oldDelegate.frameNumber; +} From c4816c464317ce3316bfa4cb2337f140d1492930 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sun, 10 Nov 2024 18:04:10 -0500 Subject: [PATCH 14/30] Improve audio sync --- lib/src/models/view/map.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 28afc3b51..dd341ef14 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -275,16 +275,18 @@ class AutonomyModel with ChangeNotifier { zoom(50); badAppleFrame = 0; Timer.run(() async { - await badAppleAudioPlayer.setAsset("assets/bad_apple2.mp3"); - _badAppleStopwatch.start(); + await badAppleAudioPlayer.setAsset("assets/bad_apple2.mp3", preload: false); badAppleAudioPlayer.play().ignore(); + _badAppleStopwatch.start(); }); while (isPlayingBadApple) { await Future.delayed(Duration.zero); - badAppleFrame = - ((_badAppleStopwatch.elapsedMicroseconds.toDouble() / 1e6) * 30.0) - .round(); + var sampleTime = _badAppleStopwatch.elapsed; + if (badAppleAudioPlayer.position != Duration.zero) { + sampleTime = badAppleAudioPlayer.position; + } + badAppleFrame = ((sampleTime.inMicroseconds.toDouble() / 1e6) * 30.0).round(); if (badAppleFrame >= badAppleLastFrame) { stopBadApple(); break; From a4e83cd91f127a39c7e566c6a3e8078a8a2bde8c Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sun, 10 Nov 2024 18:44:56 -0500 Subject: [PATCH 15/30] Allow bad apple to be played while connected to autonomy --- lib/src/models/view/map.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index dd341ef14..427afe2d0 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -215,7 +215,9 @@ class AutonomyModel with ChangeNotifier { /// A handler to call when new data arrives. Updates [data] and the UI. void onNewData(AutonomyData value) { - data = value; + if (!isPlayingBadApple) { + data = value; + } services.files.logData(value); notifyListeners(); } From 62dcf9d4fbce0c8dd2cacf0b99d7bb2ccdae29e9 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 12 Nov 2024 20:34:35 -0500 Subject: [PATCH 16/30] Increased precision of gps metrics --- lib/src/data/metrics/position.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/data/metrics/position.dart b/lib/src/data/metrics/position.dart index 30d051420..f43e4ccb0 100644 --- a/lib/src/data/metrics/position.dart +++ b/lib/src/data/metrics/position.dart @@ -37,8 +37,8 @@ class PositionMetrics extends Metrics { @override List get allMetrics => [ MetricLine("GPS: "), - MetricLine(" Latitude: ${data.gps.latitude.toStringAsFixed(6)}°",), - MetricLine(" Longitude: ${data.gps.longitude.toStringAsFixed(6)}°",), + MetricLine(" Latitude: ${data.gps.latitude.toStringAsFixed(10)}°",), + MetricLine(" Longitude: ${data.gps.longitude.toStringAsFixed(10)}°",), MetricLine(" Altitude: ${data.gps.altitude.toStringAsFixed(2)} m"), MetricLine("Orientation:",), MetricLine(" X: ${data.orientation.x.toStringAsFixed(2)}°", severity: getRotationSeverity(data.orientation.x)), From adb5a800f967b955eb456258a52b17131e013e83 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 12 Nov 2024 20:40:11 -0500 Subject: [PATCH 17/30] Fixed "place markers here" --- lib/src/models/view/map.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 427afe2d0..712921661 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -126,7 +126,7 @@ class AutonomyModel with ChangeNotifier { /// The cell type of the rover that isn't [AutonomyCell.rover] AutonomyCell get roverCellType { - final roverCoordinates = roverPosition..toGridBlock; + final roverCoordinates = roverPosition.toGridBlock; if (data.hasDestination() && data.destination == roverCoordinates) { return AutonomyCell.destination; @@ -224,7 +224,7 @@ class AutonomyModel with ChangeNotifier { /// Places the marker at [coordinates]. void placeMarker(GpsCoordinates coordinates) { - markers.add(coordinates); + markers.add(coordinates.deepCopy()); notifyListeners(); } From 1b9bf41b946fd6e19296abe7d0ce639e3baa946c Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 12 Nov 2024 20:54:50 -0500 Subject: [PATCH 18/30] Fixed displaying cells behind rover --- lib/src/models/view/map.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 712921661..57febdf0a 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -128,11 +128,11 @@ class AutonomyModel with ChangeNotifier { AutonomyCell get roverCellType { final roverCoordinates = roverPosition.toGridBlock; - if (data.hasDestination() && data.destination == roverCoordinates) { + if (data.hasDestination() && data.destination.toGridBlock == roverCoordinates) { return AutonomyCell.destination; - } else if (markers.contains(roverCoordinates)) { + } else if (markers.any((e) => e.toGridBlock == roverCoordinates)) { return AutonomyCell.marker; - } else if (data.path.contains(roverCoordinates)) { + } else if (data.path.map((e) => e.toGridBlock).contains(roverCoordinates.toGridBlock)) { return AutonomyCell.path; } @@ -230,10 +230,10 @@ class AutonomyModel with ChangeNotifier { /// Places a marker at the rover's current position. void placeMarkerOnRover() { - if (!markers.contains(roverPosition.toGridBlock)) { - markers.add(roverPosition.toGridBlock); + if (!markers.any((e) => e.toGridBlock == roverPosition.toGridBlock)) { + markers.add(roverPosition); + notifyListeners(); } - notifyListeners(); } /// Removes a marker from [gps] From 7694e089d04798347ba18271d352a58209819bef Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 12 Nov 2024 20:56:51 -0500 Subject: [PATCH 19/30] Restore original zoom after playing bad apple --- lib/src/models/view/map.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 57febdf0a..e2ec880d4 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -261,6 +261,8 @@ class AutonomyModel with ChangeNotifier { bool isPlayingBadApple = false; /// Which frame in the Bad Apple video we are up to right now int badAppleFrame = 0; + /// The zoom of the map before playing bad apple + late int _originalZoom = gridSize; /// The audio player for the Bad Apple music final badAppleAudioPlayer = AudioPlayer(); /// How many frames in a second are being shown @@ -274,6 +276,7 @@ class AutonomyModel with ChangeNotifier { Future startBadApple() async { isPlayingBadApple = true; notifyListeners(); + _originalZoom = gridSize; zoom(50); badAppleFrame = 0; Timer.run(() async { @@ -349,7 +352,7 @@ class AutonomyModel with ChangeNotifier { badAppleAudioPlayer.stop(); _badAppleStopwatch.stop(); _badAppleStopwatch.reset(); - zoom(11); + zoom(_originalZoom); notifyListeners(); } } From d7d30e3563d687cede005eaa9fe385c0cfc657e1 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 12 Nov 2024 21:15:09 -0500 Subject: [PATCH 20/30] Actually fixed the "place marker here" I swear it works now --- lib/src/models/view/map.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index e2ec880d4..d9afc790b 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -231,8 +231,7 @@ class AutonomyModel with ChangeNotifier { /// Places a marker at the rover's current position. void placeMarkerOnRover() { if (!markers.any((e) => e.toGridBlock == roverPosition.toGridBlock)) { - markers.add(roverPosition); - notifyListeners(); + placeMarker(roverPosition); } } From 427097984ac75e7cf66bb28c0db561b73ffb26d6 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Tue, 19 Nov 2024 11:04:04 -0500 Subject: [PATCH 21/30] Split map page into files --- lib/src/models/rover/rover.dart | 6 +- lib/src/models/view/map.dart | 35 +- lib/src/pages/map.dart | 539 ++++++------------- lib/src/pages/map/bad_apple.dart | 95 ++++ lib/src/pages/map/header.dart | 43 ++ lib/src/pages/map/legend.dart | 83 +++ lib/src/widgets/atomic/autonomy_command.dart | 64 +-- 7 files changed, 440 insertions(+), 425 deletions(-) create mode 100644 lib/src/pages/map/bad_apple.dart create mode 100644 lib/src/pages/map/header.dart create mode 100644 lib/src/pages/map/legend.dart diff --git a/lib/src/models/rover/rover.dart b/lib/src/models/rover/rover.dart index b2e287419..6b134dd87 100644 --- a/lib/src/models/rover/rover.dart +++ b/lib/src/models/rover/rover.dart @@ -7,7 +7,7 @@ import "package:rover_dashboard/models.dart"; import "settings.dart"; /// The model to control the entire rover. -/// +/// /// Find more specific functionality in this class's fields. class Rover extends Model { /// Monitors metrics coming from the rover. @@ -41,13 +41,13 @@ class Rover extends Model { Iterable get controllers => [controller1, controller2, controller3]; /// Whether the rover is connected. - bool get isConnected => models.sockets.data.isConnected; + bool get isConnected => models.sockets.sockets.any((socket) => socket.isConnected); /// The current status of the rover. ValueNotifier status = ValueNotifier(RoverStatus.DISCONNECTED); @override - Future init() async { + Future init() async { setDefaultControls(); await metrics.init(); await controller1.init(); diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index d9afc790b..7c744929b 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -123,7 +123,7 @@ class AutonomyModel with ChangeNotifier { /// The rover's current position. GpsCoordinates get roverPosition => models.rover.metrics.position.data.gps; - + /// The cell type of the rover that isn't [AutonomyCell.rover] AutonomyCell get roverCellType { final roverCoordinates = roverPosition.toGridBlock; @@ -251,6 +251,39 @@ class AutonomyModel with ChangeNotifier { notifyListeners(); } + /// Builder for autonomy commands + final AutonomyCommandBuilder commandBuilder = AutonomyCommandBuilder(); + + /// Adds or removes a marker at the given location. + void toggleMarker(MapCellData cell) { + if (markers.contains(cell.coordinates)) { + removeMarker(cell.coordinates); + } else { + placeMarker(cell.coordinates); + } + } + + /// Handles when a specific tile was dropped onto a grid cell. + /// + /// - If it's a destination tile, then the rover will go there + /// - If it's an obstacle tile, the rover will avoid it + /// - If it's a marker tile, draws or removes a Dashboard marker + void handleDrag(AutonomyCell data, MapCellData cell) { + switch (data) { + case AutonomyCell.destination: + commandBuilder.gps.latDecimal.value = cell.coordinates.latitude; + commandBuilder.gps.longDecimal.value = cell.coordinates.longitude; + commandBuilder.submit(); + case AutonomyCell.obstacle: + final obstacleData = AutonomyData(obstacles: [cell.coordinates]); + models.sockets.autonomy.sendMessage(obstacleData); + case AutonomyCell.marker: toggleMarker(cell); + case AutonomyCell.rover: break; + case AutonomyCell.path: break; + case AutonomyCell.empty: break; + } + } + // ==================== Bad Apple Easter Egg ==================== // // This Easter Egg renders the Bad Apple video in the map page by grabbing diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index 5500993b2..b47b0bf9e 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -5,6 +5,10 @@ import "package:flutter/material.dart"; import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/widgets.dart"; +import "map/bad_apple.dart"; +import "map/header.dart"; +import "map/legend.dart"; + /// The UI for the autonomy subsystem. /// /// Displays a bird's-eye view of the rover and its path to the goal. @@ -19,428 +23,185 @@ class MapPage extends ReactiveWidget { AutonomyCell.rover => getColor(model.roverCellType, model), }; - /// Opens a dialog to prompt the user for GPS coordinates and places a marker there. - void placeMarker(BuildContext context, AutonomyModel model) => - showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text("Add a Marker"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [GpsEditor(model.markerBuilder)], - ), - actions: [ - TextButton( - child: const Text("Cancel"), - onPressed: () => Navigator.of(context).pop(), - ), - ElevatedButton( - onPressed: model.markerBuilder.isValid - ? () { - model.placeMarker(model.markerBuilder.value); - model.markerBuilder.clear(); - Navigator.of(context).pop(); - } - : null, - child: const Text("Add"), - ), - ], + /// Places a marker at the given location and closes the dialog opened by [promptForMarker]. + void placeMarker(BuildContext context, AutonomyModel model, GpsCoordinates coordinates) { + model.placeMarker(model.markerBuilder.value); + model.markerBuilder.clear(); + Navigator.of(context).pop(); + } + + /// Opens a dialog to prompt the user for GPS coordinates and calls [placeMarker]. + void promptForMarker(BuildContext context, AutonomyModel model) => showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text("Add a Marker"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [GpsEditor(model.markerBuilder)], + ), + actions: [ + TextButton( + child: const Text("Cancel"), + onPressed: () => Navigator.of(context).pop(), + ), + ElevatedButton( + onPressed: model.markerBuilder.isValid + ? () => placeMarker(context, model, model.markerBuilder.value) + : null, + child: const Text("Add"), ), - ); + ], + ), + ); /// The index of this view. final int index; - /// Builder for autonomy commands - final AutonomyCommandBuilder commandBuilder = AutonomyCommandBuilder(); - /// A const constructor. - MapPage({required this.index}); + const MapPage({required this.index}); @override AutonomyModel createModel() => AutonomyModel(); /// Creates a widget to display the cell data for the provided [cell] Widget createCell(AutonomyModel model, MapCellData cell) => Expanded( - child: DragTarget( - onAcceptWithDetails: (details) { - switch (details.data) { - case AutonomyCell.destination: - { - commandBuilder.gps.latDecimal.value = - cell.coordinates.latitude; - commandBuilder.gps.longDecimal.value = - cell.coordinates.longitude; - - commandBuilder.submit(); - break; - } - case AutonomyCell.obstacle: - { - final obstacleData = - AutonomyData(obstacles: [cell.coordinates]); - models.sockets.autonomy.sendMessage(obstacleData); - break; - } - case AutonomyCell.marker: - { - if (model.markers.contains(cell.coordinates)) { - model.removeMarker(cell.coordinates); - } else { - model.placeMarker(cell.coordinates); - } - break; - } - // ignore: no_default_cases - default: - break; - } - }, - builder: (context, candidates, rejects) => GestureDetector( - onTap: () { - if (model.markers.contains(cell.coordinates)) { - model.removeMarker(cell.coordinates); - } else { - model.placeMarker(cell.coordinates); - } - }, - child: Container( - width: 24, - decoration: BoxDecoration( - color: getColor(cell.cellType, model), - border: Border.all(), + child: DragTarget( + onAcceptWithDetails: (details) => model.handleDrag(details.data, cell), + builder: (context, candidates, rejects) => GestureDetector( + onTap: () => model.toggleMarker(cell), + child: Container( + width: 24, + decoration: BoxDecoration( + color: getColor(cell.cellType, model), + border: Border.all(), + ), + child: cell.cellType != AutonomyCell.rover ? null : LayoutBuilder( + builder: (context, constraints) => Container( + color: Colors.blue, + width: double.infinity, + height: double.infinity, + margin: EdgeInsets.all(constraints.maxWidth / 15), + child: Transform.rotate( + angle: -model.roverHeading * pi / 180, + child: Icon( + Icons.arrow_upward, + size: constraints.maxWidth * 24 / 30, + ), ), - child: cell.cellType != AutonomyCell.rover - ? null - : LayoutBuilder( - builder: (context, constraints) => Container( - color: Colors.blue, - width: double.infinity, - height: double.infinity, - margin: EdgeInsets.all(constraints.maxWidth / 15), - child: Transform.rotate( - angle: -model.roverHeading * pi / 180, - child: Icon( - Icons.arrow_upward, - size: constraints.maxWidth * 24 / 30, - ), - ), - ), - ), ), ), ), - ); + ), + ), + ); /// The controls for creating, placing, and removing markers Widget markerControls(BuildContext context, AutonomyModel model) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Controls - Text("Place Marker: ", style: context.textTheme.titleLarge), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text("Add Marker"), - onPressed: () => placeMarker(context, model), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.location_on), - label: const Text("Drop Marker Here"), - onPressed: model.placeMarkerOnRover, - ), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.clear), - label: const Text("Clear All"), - onPressed: model.clearMarkers, - ), - ], - ); + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Controls + Text("Place Marker: ", style: context.textTheme.titleLarge), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text("Add Marker"), + onPressed: () => promptForMarker(context, model), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.location_on), + label: const Text("Drop Marker Here"), + onPressed: model.placeMarkerOnRover, + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.clear), + label: const Text("Clear All"), + onPressed: model.clearMarkers, + ), + ], + ); @override Widget build(BuildContext context, AutonomyModel model) => LayoutBuilder( - builder: (context, constraints) => Column( - children: [ - MapPageHeader(model: model, index: index), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (constraints.maxWidth > 700) ...[ - const SizedBox(width: 16), - const MapLegend(), - ], - const SizedBox(width: 16), - Flexible( - flex: 10, - child: AspectRatio( - aspectRatio: 1, - child: (!model.isPlayingBadApple) - ? Column( + builder: (context, constraints) => Column( + children: [ + MapPageHeader(model: model, index: index), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (constraints.maxWidth > 700) ...[ + const SizedBox(width: 16), + const MapLegend(), + ], + const SizedBox(width: 16), + Flexible( + flex: 10, + child: AspectRatio( + aspectRatio: 1, + child: (!model.isPlayingBadApple) + ? Column( + children: [ + for (final row in model.grid.reversed) + Expanded( + child: Row( children: [ - for (final row in model.grid.reversed) - Expanded( - child: Row( - children: [ - for (final cell in row) - createCell(model, cell), - ], - ), - ), + for (final cell in row) + createCell(model, cell), ], - ) - : CustomPaint( - painter: _BadApplePainter( - frameNumber: model.badAppleFrame, - obstacles: model.data.obstacles, - ), ), - ), - ), - const SizedBox(width: 16), - const Spacer(), - ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 250, - ), - child: AbsorbPointer( - absorbing: model.isPlayingBadApple, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + ), + ], + ) + : CustomPaint( + painter: BadApplePainter( + frameNumber: model.badAppleFrame, + obstacles: model.data.obstacles, + ), + ), + ), + ), + const SizedBox(width: 16), + const Spacer(), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 250, + ), + child: AbsorbPointer( + absorbing: model.isPlayingBadApple, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + markerControls(context, model), + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - markerControls(context, model), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Zoom:", - style: context.textTheme.titleLarge, - ), - Slider( - value: model.gridSize.clamp(1, 41).toDouble(), - min: 1, - max: 41, - divisions: 20, - label: "${model.gridSize}x${model.gridSize}", - onChanged: (value) => model.zoom(value.toInt()), - ), - ], + Text( + "Scale:", + style: context.textTheme.titleLarge, + ), + Slider( + value: model.gridSize.clamp(1, 41).toDouble(), + min: 1, + max: 41, + divisions: 20, + label: "${model.gridSize}x${model.gridSize}", + onChanged: (value) => model.zoom(value.toInt()), ), - AutonomyCommandEditor(commandBuilder, model), ], ), - ), + AutonomyCommandEditor(model.commandBuilder, model), + ], ), - const SizedBox(width: 16), - ], + ), ), - ), - ], - ), - ); -} - -/// Displays the legend for what each cell color represents on the map -class MapLegend extends StatelessWidget { - /// Const constructor for the map legend - const MapLegend({super.key}); - - @override - Widget build(BuildContext context) => Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text("Legend:", style: context.textTheme.titleLarge), - Column( - children: [ - Container(width: 24, height: 24, color: Colors.blue), - const SizedBox(height: 2), - Text("Rover", style: context.textTheme.titleMedium), + const SizedBox(width: 16), ], ), - Draggable( - data: AutonomyCell.destination, - dragAnchorStrategy: (draggable, context, position) => - const Offset(12, 12), - feedback: Container( - width: 24, - height: 24, - color: Colors.green.withOpacity(0.75), - ), - child: Column( - children: [ - Container(width: 24, height: 24, color: Colors.green), - const SizedBox(height: 2), - Text("Destination", style: context.textTheme.titleMedium), - ], - ), - ), - Draggable( - data: AutonomyCell.obstacle, - dragAnchorStrategy: (draggable, context, position) => - const Offset(12, 12), - feedback: Container( - width: 24, - height: 24, - color: Colors.black.withOpacity(0.75), - ), - child: Column( - children: [ - Container(width: 24, height: 24, color: Colors.black), - const SizedBox(height: 2), - Text("Obstacle", style: context.textTheme.titleMedium), - ], - ), - ), - Column( - children: [ - Container(width: 24, height: 24, color: Colors.blueGrey), - const SizedBox(height: 2), - Text("Path", style: context.textTheme.titleMedium), - ], - ), - Draggable( - data: AutonomyCell.marker, - dragAnchorStrategy: (draggable, context, position) => - const Offset(12, 12), - feedback: Container( - width: 24, - height: 24, - color: Colors.red.withOpacity(0.75), - ), - child: Column( - children: [ - Container(width: 24, height: 24, color: Colors.red), - const SizedBox(height: 2), - Text("Marker", style: context.textTheme.titleMedium), - ], - ), - ), - ], - ); -} - -/// The header to appear at the top of the map page, displays a button to play bad apple... -class MapPageHeader extends StatelessWidget { - /// The model to control the status of bad apple... - final AutonomyModel model; - - /// Index of the view the header is in - final int index; - - /// Const constructor for the map page header - const MapPageHeader({required this.index, required this.model, super.key}); - - @override - Widget build(BuildContext context) => Container( - color: context.colorScheme.surface, - height: 50, - child: Row( - children: [ - // The header at the top - const SizedBox(width: 8), - Text("Map", style: context.textTheme.headlineMedium), - if (models.settings.easterEggs.badApple) - IconButton( - iconSize: 48, - icon: CircleAvatar( - backgroundImage: - const AssetImage("assets/bad_apple_thumbnail.webp"), - child: model.isPlayingBadApple - ? const Icon(Icons.block, color: Colors.red, size: 36) - : null, - ), - onPressed: model.isPlayingBadApple - ? model.stopBadApple - : model.startBadApple, - ), - const Spacer(), - ViewsSelector(index: index), - ], ), - ); -} - -class _BadApplePainter extends CustomPainter { - final int frameNumber; - final List obstacles; - - _BadApplePainter({required this.frameNumber, required this.obstacles}); - - @override - void paint(Canvas canvas, Size size) { - canvas.drawRect( - Rect.fromLTRB(0, 0, size.width, size.height), - Paint() - ..color = Colors.white - ..style = PaintingStyle.fill, - ); - drawGrid(canvas, size); - drawPixels(canvas, size); - } - - void drawGrid(Canvas canvas, Size size) { - final linePaint = Paint() - ..color = Colors.black - ..strokeWidth = 2 - ..strokeCap = StrokeCap.butt; - - final borderPaint = Paint() - ..color = Colors.black - ..strokeWidth = 1 - ..strokeCap = StrokeCap.square - ..style = PaintingStyle.stroke; - - // Columns - for (var i = 1; i <= 49; i++) { - canvas.drawLine( - Offset(i * size.width / 50, 0), - Offset(i * size.width / 50, size.height), - linePaint, - ); - } - - // Rows - for (var i = 1; i <= 49; i++) { - canvas.drawLine( - Offset(0, i * size.height / 50), - Offset(size.width, i * size.height / 50), - linePaint, - ); - } - - canvas.drawRect( - Rect.fromCenter( - center: Offset(size.width, size.height) / 2, - width: size.width - 1, - height: size.height - 1, - ), - borderPaint, - ); - } - - void drawPixels(Canvas canvas, Size size) { - final boxWidth = size.width / 50; - final boxHeight = size.height / 50; - - final paint = Paint() - ..color = Colors.black - ..style = PaintingStyle.fill; - - for (final coordinates in obstacles) { - final rect = Rect.fromLTWH( - size.width - (coordinates.longitude + 1) * boxHeight, - size.height - coordinates.latitude * boxWidth, - boxWidth, - boxHeight, - ); - canvas.drawRect(rect, paint); - } - } - - @override - bool shouldRepaint(_BadApplePainter oldDelegate) => - frameNumber != oldDelegate.frameNumber; + ], + ), + ); } diff --git a/lib/src/pages/map/bad_apple.dart b/lib/src/pages/map/bad_apple.dart new file mode 100644 index 000000000..5f0ff3394 --- /dev/null +++ b/lib/src/pages/map/bad_apple.dart @@ -0,0 +1,95 @@ + +import "package:flutter/material.dart"; + +import "package:rover_dashboard/data.dart"; + +/// A [CustomPainter] that can efficiently render [Bad Apple](https://www.youtube.com/watch?v=MVrNn5TuMkY). +/// +/// This class is not responsible for loading the data, only for the final render of the frames. +class BadApplePainter extends CustomPainter { + /// The current frame this painter is rendering. + final int frameNumber; + + /// The list of "obstacles" to render, in the shape of the current frame. + final List obstacles; + + /// Creates a painter that renders the given frame of Bad Apple. + BadApplePainter({required this.frameNumber, required this.obstacles}); + + @override + void paint(Canvas canvas, Size size) { + canvas.drawRect( + Rect.fromLTRB(0, 0, size.width, size.height), + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill, + ); + drawGrid(canvas, size); + drawPixels(canvas, size); + } + + /// Draws an empty grid for this frame. + void drawGrid(Canvas canvas, Size size) { + final linePaint = Paint() + ..color = Colors.black + ..strokeWidth = 2 + ..strokeCap = StrokeCap.butt; + + final borderPaint = Paint() + ..color = Colors.black + ..strokeWidth = 1 + ..strokeCap = StrokeCap.square + ..style = PaintingStyle.stroke; + + // Columns + for (var i = 1; i <= 49; i++) { + canvas.drawLine( + Offset(i * size.width / 50, 0), + Offset(i * size.width / 50, size.height), + linePaint, + ); + } + + // Rows + for (var i = 1; i <= 49; i++) { + canvas.drawLine( + Offset(0, i * size.height / 50), + Offset(size.width, i * size.height / 50), + linePaint, + ); + } + + canvas.drawRect( + Rect.fromCenter( + center: Offset(size.width, size.height) / 2, + width: size.width - 1, + height: size.height - 1, + ), + borderPaint, + ); + } + + /// Draws pixels on the grid according to [obstacles]. + void drawPixels(Canvas canvas, Size size) { + final boxWidth = size.width / 50; + final boxHeight = size.height / 50; + + final paint = Paint() + ..color = Colors.black + ..style = PaintingStyle.fill; + + for (final coordinates in obstacles) { + final rect = Rect.fromLTWH( + size.width - (coordinates.longitude + 1) * boxHeight, + size.height - coordinates.latitude * boxWidth, + boxWidth, + boxHeight, + ); + canvas.drawRect(rect, paint); + } + } + + @override + bool shouldRepaint(BadApplePainter oldDelegate) => + frameNumber != oldDelegate.frameNumber; +} diff --git a/lib/src/pages/map/header.dart b/lib/src/pages/map/header.dart new file mode 100644 index 000000000..f0d201754 --- /dev/null +++ b/lib/src/pages/map/header.dart @@ -0,0 +1,43 @@ + +import "package:flutter/material.dart"; + +import "package:rover_dashboard/models.dart"; +import "package:rover_dashboard/widgets.dart"; + +/// The header to appear at the top of the map page, displays a button to play bad apple... +class MapPageHeader extends StatelessWidget { + /// The model to control the status of bad apple... + final AutonomyModel model; + + /// Index of the view the header is in + final int index; + + /// Const constructor for the map page header + const MapPageHeader({required this.index, required this.model, super.key}); + + @override + Widget build(BuildContext context) => Container( + color: context.colorScheme.surface, + height: 50, + child: Row( + children: [ + const SizedBox(width: 8), + Text("Map", style: context.textTheme.headlineMedium), + if (models.settings.easterEggs.badApple) IconButton( + iconSize: 48, + icon: CircleAvatar( + backgroundImage: const AssetImage("assets/bad_apple_thumbnail.webp"), + child: model.isPlayingBadApple + ? const Icon(Icons.block, color: Colors.red, size: 36) + : null, + ), + onPressed: model.isPlayingBadApple + ? model.stopBadApple + : model.startBadApple, + ), + const Spacer(), + ViewsSelector(index: index), + ], + ), + ); +} diff --git a/lib/src/pages/map/legend.dart b/lib/src/pages/map/legend.dart new file mode 100644 index 000000000..4ae836694 --- /dev/null +++ b/lib/src/pages/map/legend.dart @@ -0,0 +1,83 @@ + +import "package:flutter/material.dart"; +import "package:rover_dashboard/models.dart"; +import "package:rover_dashboard/widgets.dart"; + +/// Displays the legend for what each cell color represents on the map +class MapLegend extends StatelessWidget { + /// Const constructor for the map legend + const MapLegend({super.key}); + + @override + Widget build(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text("Legend:", style: context.textTheme.titleLarge), + Column( + children: [ + Container(width: 24, height: 24, color: Colors.blue), + const SizedBox(height: 2), + Text("Rover", style: context.textTheme.titleMedium), + ], + ), + Draggable( + data: AutonomyCell.destination, + dragAnchorStrategy: (draggable, context, position) => + const Offset(12, 12), + feedback: Container( + width: 24, + height: 24, + color: Colors.green.withOpacity(0.75), + ), + child: Column( + children: [ + Container(width: 24, height: 24, color: Colors.green), + const SizedBox(height: 2), + Text("Destination", style: context.textTheme.titleMedium), + ], + ), + ), + Draggable( + data: AutonomyCell.obstacle, + dragAnchorStrategy: (draggable, context, position) => + const Offset(12, 12), + feedback: Container( + width: 24, + height: 24, + color: Colors.black.withOpacity(0.75), + ), + child: Column( + children: [ + Container(width: 24, height: 24, color: Colors.black), + const SizedBox(height: 2), + Text("Obstacle", style: context.textTheme.titleMedium), + ], + ), + ), + Column( + children: [ + Container(width: 24, height: 24, color: Colors.blueGrey), + const SizedBox(height: 2), + Text("Path", style: context.textTheme.titleMedium), + ], + ), + Draggable( + data: AutonomyCell.marker, + dragAnchorStrategy: (draggable, context, position) => + const Offset(12, 12), + feedback: Container( + width: 24, + height: 24, + color: Colors.red.withOpacity(0.75), + ), + child: Column( + children: [ + Container(width: 24, height: 24, color: Colors.red), + const SizedBox(height: 2), + Text("Marker", style: context.textTheme.titleMedium), + ], + ), + ), + ], + ); +} diff --git a/lib/src/widgets/atomic/autonomy_command.dart b/lib/src/widgets/atomic/autonomy_command.dart index a53c918c1..ebdc0984f 100644 --- a/lib/src/widgets/atomic/autonomy_command.dart +++ b/lib/src/widgets/atomic/autonomy_command.dart @@ -44,36 +44,36 @@ class AutonomyCommandEditor extends ReusableReactiveWidget Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Autonomy:", style: context.textTheme.titleLarge), - Text( - "${dataModel.data.state.humanName}, ${dataModel.data.task.humanName}", - style: context.textTheme.titleMedium, - ), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text("New Task"), - onPressed: () { - if (RoverStatus.AUTONOMOUS == models.rover.status.value) { - createTask(context, model); - } else { - models.home.setMessage( - severity: Severity.error, - text: "You must be in autonomy mode to do that", - ); - } - }, - ), - const SizedBox(height: 8), - ElevatedButton( - style: const ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.red), - ), - onPressed: model.abort, - child: const Text("ABORT"), - ), - ], - ); + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Autonomy:", style: context.textTheme.titleLarge), + Text( + "${dataModel.data.state.humanName}, ${dataModel.data.task.humanName}", + style: context.textTheme.titleMedium, + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text("New Task"), + onPressed: () { + if (!models.rover.isConnected || RoverStatus.AUTONOMOUS == models.rover.status.value) { + createTask(context, model); + } else { + models.home.setMessage( + severity: Severity.error, + text: "You must be in autonomy mode to do that", + ); + } + }, + ), + const SizedBox(height: 8), + ElevatedButton( + style: const ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.red), + ), + onPressed: model.abort, + child: const Text("ABORT"), + ), + ], + ); } From 9d53b59059f32574d4580ba506bae927c1005502 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Tue, 19 Nov 2024 11:27:06 -0500 Subject: [PATCH 22/30] Stop autonomy task unless in autonomy mode --- lib/src/models/view/map.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 7c744929b..2ac8ab67e 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -271,6 +271,10 @@ class AutonomyModel with ChangeNotifier { void handleDrag(AutonomyCell data, MapCellData cell) { switch (data) { case AutonomyCell.destination: + if (models.rover.isConnected && RoverStatus.AUTONOMOUS != models.rover.status.value) { + models.home.setMessage(severity: Severity.error, text: "You must be in autonomy mode"); + return; + } commandBuilder.gps.latDecimal.value = cell.coordinates.latitude; commandBuilder.gps.longDecimal.value = cell.coordinates.longitude; commandBuilder.submit(); From b55d287d3604423868c12464234963bd6acb1b9d Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Tue, 19 Nov 2024 11:41:30 -0500 Subject: [PATCH 23/30] Moved Bad Apple to a new file --- lib/src/models/view/bad_apple.dart | 133 +++++++++++++++++++++++++ lib/src/models/view/map.dart | 154 +++++------------------------ 2 files changed, 155 insertions(+), 132 deletions(-) create mode 100644 lib/src/models/view/bad_apple.dart diff --git a/lib/src/models/view/bad_apple.dart b/lib/src/models/view/bad_apple.dart new file mode 100644 index 000000000..d39b3275b --- /dev/null +++ b/lib/src/models/view/bad_apple.dart @@ -0,0 +1,133 @@ +import "dart:async"; +import "dart:ui"; + +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:just_audio/just_audio.dart"; +import "package:rover_dashboard/data.dart"; +import "package:rover_dashboard/models.dart"; + +/// The Bad Apple Easter Egg. +/// +/// This Easter Egg renders the Bad Apple video in the map page by grabbing +/// each frame and assigning an obstacle to each black pixel. +mixin BadAppleViewModel on ChangeNotifier { + /// Whether the UI is currently playing Bad Apple + bool isPlayingBadApple = false; + + /// Which frame in the Bad Apple video we are up to right now + int badAppleFrame = 0; + + /// The zoom of the map before playing bad apple + late int _originalZoom = gridSize; + + /// The audio player for the Bad Apple music + final badAppleAudioPlayer = AudioPlayer(); + + /// How many frames in a second are being shown + static const badAppleFps = 1; + + /// The last frame of Bad Apple + static const badAppleLastFrame = 6570; + + /// A stopwatch to track the current time in the bad apple video + final Stopwatch _badAppleStopwatch = Stopwatch(); + + /// Cleans up any resources used by Bad Apple. + void disposeBadApple() => badAppleAudioPlayer.dispose(); + + /// The amount of blocks in the width and height of the grid. + /// + /// Keep this an odd number to keep the rover in the center. + int get gridSize; + + /// Sets the zoom of the map. + void zoom(int newSize); + + /// Sets the grid data of the map. + set data(AutonomyData value); + + /// Starts playing Bad Apple. + Future startBadApple() async { + isPlayingBadApple = true; + notifyListeners(); + _originalZoom = gridSize; + zoom(50); + badAppleFrame = 0; + Timer.run(() async { + await badAppleAudioPlayer.setAsset("assets/bad_apple2.mp3", preload: false); + badAppleAudioPlayer.play().ignore(); + _badAppleStopwatch.start(); + }); + + while (isPlayingBadApple) { + await Future.delayed(Duration.zero); + var sampleTime = _badAppleStopwatch.elapsed; + if (badAppleAudioPlayer.position != Duration.zero) { + sampleTime = badAppleAudioPlayer.position; + } + badAppleFrame = ((sampleTime.inMicroseconds.toDouble() / 1e6) * 30.0).round(); + if (badAppleFrame >= badAppleLastFrame) { + stopBadApple(); + break; + } + final obstacles = await _loadBadAppleFrame(badAppleFrame); + if (obstacles == null) { + continue; + } + if (!isPlayingBadApple) { + break; + } + data = AutonomyData(obstacles: obstacles); + notifyListeners(); + } + } + + Future?> _loadBadAppleFrame(int videoFrame) async { + // final filename = "assets/bad_apple/image_480.jpg"; + final filename = "assets/bad_apple/image_$videoFrame.jpg"; + final buffer = await rootBundle.loadBuffer(filename); + final codec = await instantiateImageCodecWithSize(buffer); + final frame = await codec.getNextFrame(); + final image = frame.image; + if (image.height != 50 || image.width != 50) { + models.home.setMessage(severity: Severity.error, text: "Wrong Bad Apple frame size"); + stopBadApple(); + return null; + } + final imageData = await image.toByteData(); + if (imageData == null) { + models.home.setMessage(severity: Severity.error, text: "Could not load Bad Apple frame"); + stopBadApple(); + return null; + } + var offset = 0; + final obstacles = []; + for (var row = 0; row < image.height; row++) { + for (var col = 0; col < image.width; col++) { + final pixel = imageData.getUint8(offset++); + imageData.getUint8(offset++); + imageData.getUint8(offset++); + imageData.getUint8(offset++); + final isBlack = pixel < 100; // dealing with lossy compression, not 255 and 0 + final coordinate = GpsCoordinates(latitude: (row - image.height).abs().toDouble(), longitude: (image.width - col - 1).abs().toDouble()); + if (isBlack) obstacles.add(coordinate); + } + } + if (!isPlayingBadApple) { + return null; + } + return obstacles; + } + + /// Stops playing Bad Apple and resets the UI. + void stopBadApple() { + isPlayingBadApple = false; + data = AutonomyData(); + badAppleAudioPlayer.stop(); + _badAppleStopwatch.stop(); + _badAppleStopwatch.reset(); + zoom(_originalZoom); + notifyListeners(); + } +} diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 2ac8ab67e..25e2b5096 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -1,13 +1,12 @@ import "dart:async"; -import "dart:ui"; import "package:flutter/material.dart"; -import "package:flutter/services.dart"; -import "package:just_audio/just_audio.dart"; import "package:rover_dashboard/data.dart"; import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/services.dart"; +import "bad_apple.dart"; + /// Represents the state of a cell on the autonomy map. enum AutonomyCell { /// This is where the rover currently is. @@ -40,11 +39,9 @@ class GridOffset { extension _GpsCoordinatesToBlock on GpsCoordinates { GpsCoordinates get toGridBlock => GpsCoordinates( - latitude: - (latitude / models.settings.dashboard.mapBlockSize).roundToDouble(), - longitude: - (longitude / models.settings.dashboard.mapBlockSize).roundToDouble(), - ); + latitude: (latitude / models.settings.dashboard.mapBlockSize).roundToDouble(), + longitude: (longitude / models.settings.dashboard.mapBlockSize).roundToDouble(), + ); } /// A record representing data necessary to display a cell in the map @@ -60,10 +57,8 @@ typedef AutonomyGrid = List>; /// The [grid] is a 2D map of width and height [gridSize] that keeps the [roverPosition] in the /// center (by keeping track of its [offset]) and filling the other cells with [AutonomyCell]s. /// -class AutonomyModel with ChangeNotifier { - /// The amount of blocks in the width and height of the grid. - /// - /// Keep this an odd number to keep the rover in the center. +class AutonomyModel with ChangeNotifier, BadAppleViewModel { + @override int gridSize = 11; /// The offset to add to all other coordinates, based on [roverPosition]. See [recenterRover]. @@ -95,29 +90,28 @@ class AutonomyModel with ChangeNotifier { _subscription?.cancel(); models.settings.removeListener(notifyListeners); models.rover.metrics.position.removeListener(recenterRover); - badAppleAudioPlayer.dispose(); + disposeBadApple(); super.dispose(); } /// An empty grid of size [gridSize]. AutonomyGrid get empty => [ - for (int i = 0; i < gridSize; i++) - [ - for (int j = 0; j < gridSize; j++) - ( - coordinates: GpsCoordinates( - longitude: (-j.toDouble() + offset.x) * - models.settings.dashboard.mapBlockSize, - latitude: (i.toDouble() - offset.y) * - models.settings.dashboard.mapBlockSize, - ), - cellType: AutonomyCell.empty - ), - ], - ]; + for (int i = 0; i < gridSize; i++) [ + for (int j = 0; j < gridSize; j++) ( + coordinates: GpsCoordinates( + longitude: (-j.toDouble() + offset.x) * + models.settings.dashboard.mapBlockSize, + latitude: (i.toDouble() - offset.y) * + models.settings.dashboard.mapBlockSize, + ), + cellType: AutonomyCell.empty + ), + ], + ]; /// A list of markers manually placed by the user. Useful for the Extreme Retrieval Mission. List markers = []; + /// The view model to edit the coordinate of the marker. GpsBuilder markerBuilder = GpsBuilder(); @@ -207,7 +201,7 @@ class AutonomyModel with ChangeNotifier { notifyListeners(); } - /// Zooms in or out by modifying [gridSize]. + @override void zoom(int newSize) { gridSize = newSize; recenterRover(); @@ -287,108 +281,4 @@ class AutonomyModel with ChangeNotifier { case AutonomyCell.empty: break; } } - - // ==================== Bad Apple Easter Egg ==================== - // - // This Easter Egg renders the Bad Apple video in the map page by grabbing - // each frame and assigning an obstacle to each black pixel. - - /// Whether the UI is currently playing Bad Apple - bool isPlayingBadApple = false; - /// Which frame in the Bad Apple video we are up to right now - int badAppleFrame = 0; - /// The zoom of the map before playing bad apple - late int _originalZoom = gridSize; - /// The audio player for the Bad Apple music - final badAppleAudioPlayer = AudioPlayer(); - /// How many frames in a second are being shown - static const badAppleFps = 1; - /// The last frame of Bad Apple - static const badAppleLastFrame = 6570; - /// A stopwatch to track the current time in the bad apple video - final Stopwatch _badAppleStopwatch = Stopwatch(); - - /// Starts playing Bad Apple. - Future startBadApple() async { - isPlayingBadApple = true; - notifyListeners(); - _originalZoom = gridSize; - zoom(50); - badAppleFrame = 0; - Timer.run(() async { - await badAppleAudioPlayer.setAsset("assets/bad_apple2.mp3", preload: false); - badAppleAudioPlayer.play().ignore(); - _badAppleStopwatch.start(); - }); - - while (isPlayingBadApple) { - await Future.delayed(Duration.zero); - var sampleTime = _badAppleStopwatch.elapsed; - if (badAppleAudioPlayer.position != Duration.zero) { - sampleTime = badAppleAudioPlayer.position; - } - badAppleFrame = ((sampleTime.inMicroseconds.toDouble() / 1e6) * 30.0).round(); - if (badAppleFrame >= badAppleLastFrame) { - stopBadApple(); - break; - } - final obstacles = await _loadBadAppleFrame(badAppleFrame); - if (obstacles == null) { - continue; - } - if (!isPlayingBadApple) { - break; - } - data = AutonomyData(obstacles: obstacles); - notifyListeners(); - } - } - - Future?> _loadBadAppleFrame(int videoFrame) async { - // final filename = "assets/bad_apple/image_480.jpg"; - final filename = "assets/bad_apple/image_$videoFrame.jpg"; - final buffer = await rootBundle.loadBuffer(filename); - final codec = await instantiateImageCodecWithSize(buffer); - final frame = await codec.getNextFrame(); - final image = frame.image; - if (image.height != 50 || image.width != 50) { - models.home.setMessage(severity: Severity.error, text: "Wrong Bad Apple frame size"); - stopBadApple(); - return null; - } - final imageData = await image.toByteData(); - if (imageData == null) { - models.home.setMessage(severity: Severity.error, text: "Could not load Bad Apple frame"); - stopBadApple(); - return null; - } - var offset = 0; - final obstacles = []; - for (var row = 0; row < image.height; row++) { - for (var col = 0; col < image.width; col++) { - final pixel = imageData.getUint8(offset++); - imageData.getUint8(offset++); - imageData.getUint8(offset++); - imageData.getUint8(offset++); - final isBlack = pixel < 100; // dealing with lossy compression, not 255 and 0 - final coordinate = GpsCoordinates(latitude: (row - image.height).abs().toDouble(), longitude: (image.width - col - 1).abs().toDouble()); - if (isBlack) obstacles.add(coordinate); - } - } - if (!isPlayingBadApple) { - return null; - } - return obstacles; - } - - /// Stops playing Bad Apple and resets the UI. - void stopBadApple() { - isPlayingBadApple = false; - data = AutonomyData(); - badAppleAudioPlayer.stop(); - _badAppleStopwatch.stop(); - _badAppleStopwatch.reset(); - zoom(_originalZoom); - notifyListeners(); - } } From 73ff92ce571eb50f9faf80273e520e13ee744751 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Tue, 19 Nov 2024 11:49:43 -0500 Subject: [PATCH 24/30] Fixed last preset issue --- lib/src/models/data/views.dart | 3 +++ lib/src/widgets/navigation/views.dart | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/models/data/views.dart b/lib/src/models/data/views.dart index c2bd1733f..cbf4af68f 100644 --- a/lib/src/models/data/views.dart +++ b/lib/src/models/data/views.dart @@ -88,10 +88,13 @@ class ViewsModel extends Model with PresetsModel { // Wait for all views to reset so as not to cause overflow issues await nextFrame(); setNumViews(preset.views.length); + notifyListeners(); // Wait 3 frames for flutter_resizable container to load await nextFrame(); await nextFrame(); await nextFrame(); + await nextFrame(); + await nextFrame(); if (preset.horizontal1.isNotEmpty) horizontalController1.setRatios(preset.horizontal1); if (preset.horizontal2.isNotEmpty) horizontalController2.setRatios(preset.horizontal2); if (preset.horizontal3.isNotEmpty) horizontalController3.setRatios(preset.horizontal3); diff --git a/lib/src/widgets/navigation/views.dart b/lib/src/widgets/navigation/views.dart index 0d529d1dd..5fc757521 100644 --- a/lib/src/widgets/navigation/views.dart +++ b/lib/src/widgets/navigation/views.dart @@ -66,7 +66,7 @@ class ViewsWidget extends ReusableReactiveWidget { ], ), 2 => ResizableContainer( - key: const ValueKey(1), + key: ValueKey("1-${models.settings.dashboard.splitMode.name}"), direction: switch (models.settings.dashboard.splitMode) { SplitMode.horizontal => Axis.vertical, SplitMode.vertical => Axis.horizontal, From 4d72da443a22b6e0634baa527ad50b325ddb03d1 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 19 Nov 2024 20:32:23 -0500 Subject: [PATCH 25/30] Switched map to use meters --- lib/src/models/view/map.dart | 40 +++++++++++++++++++++--------------- lib/src/pages/settings.dart | 2 +- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 25e2b5096..c4775a647 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -1,4 +1,5 @@ import "dart:async"; +import "package:burt_network/burt_network.dart"; import "package:flutter/material.dart"; import "package:rover_dashboard/data.dart"; @@ -38,10 +39,12 @@ class GridOffset { } extension _GpsCoordinatesToBlock on GpsCoordinates { - GpsCoordinates get toGridBlock => GpsCoordinates( - latitude: (latitude / models.settings.dashboard.mapBlockSize).roundToDouble(), - longitude: (longitude / models.settings.dashboard.mapBlockSize).roundToDouble(), - ); + GpsCoordinates get toGridBlock { + final (latitudeMeters, longitudeMeters) = inMeters; + return GpsCoordinates( + latitude: (latitudeMeters / models.settings.dashboard.mapBlockSize).roundToDouble(), + longitude: (longitudeMeters / models.settings.dashboard.mapBlockSize).roundToDouble(), + );} } /// A record representing data necessary to display a cell in the map @@ -96,14 +99,12 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { /// An empty grid of size [gridSize]. AutonomyGrid get empty => [ - for (int i = 0; i < gridSize; i++) [ - for (int j = 0; j < gridSize; j++) ( - coordinates: GpsCoordinates( - longitude: (-j.toDouble() + offset.x) * - models.settings.dashboard.mapBlockSize, - latitude: (i.toDouble() - offset.y) * - models.settings.dashboard.mapBlockSize, - ), + for (int latitude = 0; latitude < gridSize; latitude++) [ + for (int longitude = 0; longitude < gridSize; longitude++) ( + coordinates: ( + (latitude.toDouble() - offset.y) * precisionMeters, + (-longitude.toDouble() + offset.x) * precisionMeters + ).toGpsCoordinates, cellType: AutonomyCell.empty ), ], @@ -118,6 +119,9 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { /// The rover's current position. GpsCoordinates get roverPosition => models.rover.metrics.position.data.gps; + /// The precision of the grid + double get precisionMeters => models.settings.dashboard.mapBlockSize; + /// The cell type of the rover that isn't [AutonomyCell.rover] AutonomyCell get roverCellType { final roverCoordinates = roverPosition.toGridBlock; @@ -174,8 +178,9 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { // - rover.longitude => (gridSize - 1) / 2 // - rover.latitude => (gridSize - 1) / 2 // Then, everything else should be offset by that - final x = -1 * gpsToBlock(gps.longitude) + offset.x; - final y = gpsToBlock(gps.latitude) + offset.y; + final (latitudeMeters, longitudeMeters) = gps.inMeters; + final x = (-longitudeMeters / precisionMeters).round() + offset.x; + final y = (latitudeMeters / precisionMeters).round() + offset.y; if (x < 0 || x >= gridSize) return; if (y < 0 || y >= gridSize) return; grid[y][x] = (coordinates: gps, cellType: value); @@ -193,10 +198,11 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { /// so it remains `(-1, -1)` away from the rover's new position, yielding `(4, 4)`. void recenterRover() { // final position = isPlayingBadApple ? GpsCoordinates() : roverPosition; - final position = isPlayingBadApple ? GpsCoordinates(latitude: (gridSize ~/ 2).toDouble(), longitude: (gridSize ~/ 2).toDouble()) : roverPosition; + final position = roverPosition; final midpoint = ((gridSize - 1) / 2).floor(); - final offsetX = midpoint - -1 * gpsToBlock(position.longitude); - final offsetY = midpoint - gpsToBlock(position.latitude); + final (latitudeMeters, longitudeMeters) = position.inMeters; + final offsetX = midpoint + (longitudeMeters / precisionMeters).round(); + final offsetY = midpoint - (latitudeMeters / precisionMeters).round(); offset = GridOffset(offsetX, offsetY); notifyListeners(); } diff --git a/lib/src/pages/settings.dart b/lib/src/pages/settings.dart index c770c7e65..6da5c7354 100644 --- a/lib/src/pages/settings.dart +++ b/lib/src/pages/settings.dart @@ -130,7 +130,7 @@ class SettingsPage extends ReactiveWidget { ), NumberEditor( name: "Block size", - subtitle: "The precision of the GPS grid", + subtitle: "The precision of the GPS grid in meters", model: model.dashboard.blockSize, ), SwitchListTile( From 647be887c2caac3be05bea0c59971f1afa1c33bf Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 28 Nov 2024 15:01:20 -0500 Subject: [PATCH 26/30] Bumped to burt_network 2.2.0 --- lib/src/models/view/map.dart | 27 ++++++++++++++------------- pubspec.lock | 6 +++--- pubspec.yaml | 2 +- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index c4775a647..d8211cab3 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -40,11 +40,12 @@ class GridOffset { extension _GpsCoordinatesToBlock on GpsCoordinates { GpsCoordinates get toGridBlock { - final (latitudeMeters, longitudeMeters) = inMeters; + final (:lat, :long) = inMeters; return GpsCoordinates( - latitude: (latitudeMeters / models.settings.dashboard.mapBlockSize).roundToDouble(), - longitude: (longitudeMeters / models.settings.dashboard.mapBlockSize).roundToDouble(), - );} + latitude: (lat / models.settings.dashboard.mapBlockSize).roundToDouble(), + longitude: (long / models.settings.dashboard.mapBlockSize).roundToDouble(), + ); + } } /// A record representing data necessary to display a cell in the map @@ -102,9 +103,9 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { for (int latitude = 0; latitude < gridSize; latitude++) [ for (int longitude = 0; longitude < gridSize; longitude++) ( coordinates: ( - (latitude.toDouble() - offset.y) * precisionMeters, - (-longitude.toDouble() + offset.x) * precisionMeters - ).toGpsCoordinates, + lat: (latitude.toDouble() - offset.y) * precisionMeters, + long: (-longitude.toDouble() + offset.x) * precisionMeters + ).toGps(), cellType: AutonomyCell.empty ), ], @@ -178,9 +179,9 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { // - rover.longitude => (gridSize - 1) / 2 // - rover.latitude => (gridSize - 1) / 2 // Then, everything else should be offset by that - final (latitudeMeters, longitudeMeters) = gps.inMeters; - final x = (-longitudeMeters / precisionMeters).round() + offset.x; - final y = (latitudeMeters / precisionMeters).round() + offset.y; + final (:lat, :long) = gps.inMeters; + final x = (-long / precisionMeters).round() + offset.x; + final y = (lat / precisionMeters).round() + offset.y; if (x < 0 || x >= gridSize) return; if (y < 0 || y >= gridSize) return; grid[y][x] = (coordinates: gps, cellType: value); @@ -200,9 +201,9 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { // final position = isPlayingBadApple ? GpsCoordinates() : roverPosition; final position = roverPosition; final midpoint = ((gridSize - 1) / 2).floor(); - final (latitudeMeters, longitudeMeters) = position.inMeters; - final offsetX = midpoint + (longitudeMeters / precisionMeters).round(); - final offsetY = midpoint - (latitudeMeters / precisionMeters).round(); + final (:lat, :long) = position.inMeters; + final offsetX = midpoint + (long / precisionMeters).round(); + final offsetY = midpoint - (lat / precisionMeters).round(); offset = GridOffset(offsetX, offsetY); notifyListeners(); } diff --git a/pubspec.lock b/pubspec.lock index e54d840f4..677fa900d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,11 +45,11 @@ packages: dependency: "direct main" description: path: "." - ref: "2.1.0" - resolved-ref: "61c1952afe2210099ccde59220356db647dc79ae" + ref: "2.2.0" + resolved-ref: ceb30cb12d7f3caf605d487372abafb299a7bb68 url: "https://github.com/BinghamtonRover/Networking.git" source: git - version: "2.1.0" + version: "2.2.0" characters: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 361743743..861a5cb33 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: burt_network: git: url: https://github.com/BinghamtonRover/Networking.git - ref: 2.1.0 + ref: 2.2.0 file_picker: ^8.0.0+1 fl_chart: ^0.69.0 flutter_libserialport: ^0.4.0 From a3cf4fc9a2686cd9b450b9264da2b35cb456a126 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Thu, 28 Nov 2024 15:39:14 -0500 Subject: [PATCH 27/30] Fixed paths not displaying behind rover --- lib/src/models/view/map.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index d8211cab3..3df257a4c 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -129,9 +129,11 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { if (data.hasDestination() && data.destination.toGridBlock == roverCoordinates) { return AutonomyCell.destination; - } else if (markers.any((e) => e.toGridBlock == roverCoordinates)) { + } else if (data.obstacles.map((e) => e.toGridBlock).contains(roverCoordinates)) { + return AutonomyCell.obstacle; + } else if (markers.map((e) => e.toGridBlock).contains(roverCoordinates)) { return AutonomyCell.marker; - } else if (data.path.map((e) => e.toGridBlock).contains(roverCoordinates.toGridBlock)) { + } else if (data.path.map((e) => e.toGridBlock).contains(roverCoordinates)) { return AutonomyCell.path; } From 46e8a90f562e8a4fe470fea2f3dad86577962f84 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Thu, 28 Nov 2024 18:09:05 -0500 Subject: [PATCH 28/30] Fixed New Task button --- lib/src/models/view/builders/autonomy_command.dart | 2 +- lib/src/models/view/builders/gps.dart | 4 ++-- lib/src/models/view/map.dart | 11 ++++++++--- lib/src/widgets/atomic/autonomy_command.dart | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/src/models/view/builders/autonomy_command.dart b/lib/src/models/view/builders/autonomy_command.dart index 54e86bda6..79fc07ae9 100644 --- a/lib/src/models/view/builders/autonomy_command.dart +++ b/lib/src/models/view/builders/autonomy_command.dart @@ -58,7 +58,7 @@ class AutonomyCommandBuilder extends ValueBuilder { } /// Sends this command to the rover using [Sockets.autonomy]. - Future submit() async { + Future submit(AutonomyCommand value) async { _handshake = null; isLoading = true; notifyListeners(); diff --git a/lib/src/models/view/builders/gps.dart b/lib/src/models/view/builders/gps.dart index 165049a9d..a2d133e2d 100644 --- a/lib/src/models/view/builders/gps.dart +++ b/lib/src/models/view/builders/gps.dart @@ -57,8 +57,8 @@ class GpsBuilder extends ValueBuilder { GpsCoordinates get value => switch (type) { GpsType.decimal => GpsCoordinates(longitude: longDecimal.value, latitude: latDecimal.value), GpsType.degrees => GpsCoordinates( - longitude: longDegrees.value + (longMinutes.value / 60) + (longSeconds.value / 3600), - latitude: latDegrees.value + (latMinutes.value / 60) + (latSeconds.value / 3600), + longitude: longDegrees.value + (longMinutes.value / 60.0) + (longSeconds.value / 3600.0), + latitude: latDegrees.value + (latMinutes.value / 60.0) + (latSeconds.value / 3600.0), ), }; diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 3df257a4c..2a1a3f7e8 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -278,9 +278,14 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { models.home.setMessage(severity: Severity.error, text: "You must be in autonomy mode"); return; } - commandBuilder.gps.latDecimal.value = cell.coordinates.latitude; - commandBuilder.gps.longDecimal.value = cell.coordinates.longitude; - commandBuilder.submit(); + final command = AutonomyCommand( + task: AutonomyTask.GPS_ONLY, + destination: GpsCoordinates( + latitude: cell.coordinates.latitude, + longitude: cell.coordinates.longitude, + ), + ); + commandBuilder.submit(command); case AutonomyCell.obstacle: final obstacleData = AutonomyData(obstacles: [cell.coordinates]); models.sockets.autonomy.sendMessage(obstacleData); diff --git a/lib/src/widgets/atomic/autonomy_command.dart b/lib/src/widgets/atomic/autonomy_command.dart index ebdc0984f..d8441ee8a 100644 --- a/lib/src/widgets/atomic/autonomy_command.dart +++ b/lib/src/widgets/atomic/autonomy_command.dart @@ -35,7 +35,7 @@ class AutonomyCommandEditor extends ReusableReactiveWidget Navigator.of(context).pop()), ElevatedButton( - onPressed: command.isLoading ? null : () { command.submit(); Navigator.of(context).pop(); }, + onPressed: command.isLoading ? null : () { command.submit(command.value); Navigator.of(context).pop(); }, child: const Text("Submit"), ), ], From a06de9ffdf412af78081a12dfca86f362fc72922 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sat, 14 Dec 2024 00:40:46 -0500 Subject: [PATCH 29/30] Fixed coordinate directions --- lib/src/models/view/map.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 2a1a3f7e8..d033c20c5 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -76,7 +76,6 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { /// Initializes the view model. Future init() async { recenterRover(); - await Future.delayed(const Duration(seconds: 1)); _subscription = models.messages.stream.onMessage( name: AutonomyData().messageName, constructor: AutonomyData.fromBuffer, @@ -104,7 +103,7 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { for (int longitude = 0; longitude < gridSize; longitude++) ( coordinates: ( lat: (latitude.toDouble() - offset.y) * precisionMeters, - long: (-longitude.toDouble() + offset.x) * precisionMeters + long: (longitude.toDouble() + offset.x) * precisionMeters ).toGps(), cellType: AutonomyCell.empty ), @@ -182,7 +181,7 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { // - rover.latitude => (gridSize - 1) / 2 // Then, everything else should be offset by that final (:lat, :long) = gps.inMeters; - final x = (-long / precisionMeters).round() + offset.x; + final x = (long / precisionMeters).round() - offset.x; final y = (lat / precisionMeters).round() + offset.y; if (x < 0 || x >= gridSize) return; if (y < 0 || y >= gridSize) return; @@ -204,7 +203,7 @@ class AutonomyModel with ChangeNotifier, BadAppleViewModel { final position = roverPosition; final midpoint = ((gridSize - 1) / 2).floor(); final (:lat, :long) = position.inMeters; - final offsetX = midpoint + (long / precisionMeters).round(); + final offsetX = -midpoint + (long / precisionMeters).round(); final offsetY = midpoint - (lat / precisionMeters).round(); offset = GridOffset(offsetX, offsetY); notifyListeners(); From 936d9c699cb15b4b8e35731f234325a262ab39bd Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Tue, 17 Dec 2024 21:41:26 -0500 Subject: [PATCH 30/30] Added RTK mode metrics and indicator --- lib/src/data/metrics/position.dart | 9 +++++ lib/src/pages/map/header.dart | 29 ++++++++++++++ pubspec.lock | 62 ++++++++++++++---------------- 3 files changed, 67 insertions(+), 33 deletions(-) diff --git a/lib/src/data/metrics/position.dart b/lib/src/data/metrics/position.dart index f43e4ccb0..d124a22fd 100644 --- a/lib/src/data/metrics/position.dart +++ b/lib/src/data/metrics/position.dart @@ -34,12 +34,21 @@ class PositionMetrics extends Metrics { } } + /// Gets the human friendly name of the RTK fix mode + String getRTKString(RTKMode mode) => switch(mode) { + RTKMode.RTK_FIXED => "Fixed", + RTKMode.RTK_FLOAT => "Float", + RTKMode.RTK_NONE => "None", + _ => "None", + }; + @override List get allMetrics => [ MetricLine("GPS: "), MetricLine(" Latitude: ${data.gps.latitude.toStringAsFixed(10)}°",), MetricLine(" Longitude: ${data.gps.longitude.toStringAsFixed(10)}°",), MetricLine(" Altitude: ${data.gps.altitude.toStringAsFixed(2)} m"), + MetricLine(" RTK Mode: ${getRTKString(data.gps.rtkMode)}"), MetricLine("Orientation:",), MetricLine(" X: ${data.orientation.x.toStringAsFixed(2)}°", severity: getRotationSeverity(data.orientation.x)), MetricLine(" Y: ${data.orientation.y.toStringAsFixed(2)}°", severity: getRotationSeverity(data.orientation.y)), diff --git a/lib/src/pages/map/header.dart b/lib/src/pages/map/header.dart index f0d201754..245ff91a4 100644 --- a/lib/src/pages/map/header.dart +++ b/lib/src/pages/map/header.dart @@ -1,5 +1,6 @@ import "package:flutter/material.dart"; +import "package:rover_dashboard/data.dart"; import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/widgets.dart"; @@ -15,6 +16,27 @@ class MapPageHeader extends StatelessWidget { /// Const constructor for the map page header const MapPageHeader({required this.index, required this.model, super.key}); + /// The icon for the status of RTK + Widget rtkStateIcon(BuildContext context) { + final rtkMode = model.roverPosition.rtkMode; + + final icon = switch (rtkMode) { + RTKMode.RTK_FIXED => Icons.signal_wifi_4_bar, + RTKMode.RTK_FLOAT => Icons.network_wifi_2_bar, + _ => Icons.signal_wifi_off_outlined, + }; + + return Tooltip( + message: models.rover.metrics.position.getRTKString(rtkMode), + waitDuration: const Duration(milliseconds: 500), + child: Icon( + icon, + color: context.colorScheme.onSurface, + size: 28, + ), + ); + } + @override Widget build(BuildContext context) => Container( color: context.colorScheme.surface, @@ -35,6 +57,13 @@ class MapPageHeader extends StatelessWidget { ? model.stopBadApple : model.startBadApple, ), + const SizedBox(width: 5), + Row( + children: [ + const Text("RTK Status: "), + rtkStateIcon(context), + ], + ), const Spacer(), ViewsSelector(index: index), ], diff --git a/pubspec.lock b/pubspec.lock index 677fa900d..ac885a6d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -44,12 +44,10 @@ packages: burt_network: dependency: "direct main" description: - path: "." - ref: "2.2.0" - resolved-ref: ceb30cb12d7f3caf605d487372abafb299a7bb68 - url: "https://github.com/BinghamtonRover/Networking.git" - source: git - version: "2.2.0" + path: "../SubsystemsPi/Networking" + relative: true + source: path + version: "2.3.1" characters: dependency: transitive description: @@ -86,10 +84,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" console: dependency: transitive description: @@ -186,12 +184,11 @@ packages: flutter_libserialport: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "6d8b2807c132041da9fa06c34bce8a6f831ff1f5" - url: "https://github.com/snabble/flutter_libserialport.git" - source: git - version: "0.5.0" + name: flutter_libserialport + sha256: "20c320dcde8592a16f9badc0cacad61b1fb283dbec647b6ebfc1020f8274c67b" + url: "https://pub.dev" + source: hosted + version: "0.4.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -203,12 +200,11 @@ packages: flutter_resizable_container: dependency: "direct main" description: - path: "." - ref: set-children-public - resolved-ref: cf2f237653a48389a99081dbb5086c1acce03f7f - url: "https://github.com/Levi-Lesches/flutter_resizable_container" - source: git - version: "3.0.0" + name: flutter_resizable_container + sha256: ccca2c4da19acabd7456cc6ca6724ed2d1545d97b53b87fc67cfa1c3fa754acd + url: "https://pub.dev" + source: hosted + version: "3.0.3" flutter_sdl_gamepad: dependency: "direct main" description: @@ -311,18 +307,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: @@ -527,7 +523,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: @@ -548,10 +544,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" stream_channel: dependency: transitive description: @@ -564,10 +560,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" term_glyph: dependency: transitive description: @@ -580,10 +576,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" typed_data: dependency: transitive description: @@ -692,10 +688,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.0" web: dependency: transitive description: