diff --git a/lib/models.dart b/lib/models.dart index ee4c4be39..9e151b587 100644 --- a/lib/models.dart +++ b/lib/models.dart @@ -41,6 +41,7 @@ export "src/models/view/science.dart"; // Builder models export "src/models/view/builders/autonomy_command.dart"; +export "src/models/view/builders/gps.dart"; export "src/models/view/builders/science_command.dart"; export "src/models/view/builders/mars_command.dart"; export "src/models/view/builders/builder.dart"; diff --git a/lib/pages.dart b/lib/pages.dart index 92b7924eb..3b964c0ef 100644 --- a/lib/pages.dart +++ b/lib/pages.dart @@ -27,7 +27,7 @@ class Routes { static const String science = "Science Analysis"; /// The name of the autonomy page. - static const String autonomy = "Autonomy"; + static const String autonomy = "Map"; /// The name of the MARS page. static const String mars = "MARS"; diff --git a/lib/src/data/metrics/position.dart b/lib/src/data/metrics/position.dart index 5d06fd88a..10fa29d90 100644 --- a/lib/src/data/metrics/position.dart +++ b/lib/src/data/metrics/position.dart @@ -29,7 +29,10 @@ class PositionMetrics extends Metrics { " Latitude: ${data.gps.latitude.toStringAsFixed(2)}°", " Longitude: ${data.gps.longitude.toStringAsFixed(2)}°", " Altitude: ${data.gps.altitude.toStringAsFixed(2)} m", - "Orientation: ${data.orientation.y.toStringAsFixed(2)}° of N", + "Orientation:", + " X: ${data.orientation.x.toStringAsFixed(2)}°", + " Y: ${data.orientation.y.toStringAsFixed(2)}°", + " Z: ${data.orientation.z.toStringAsFixed(2)}°", "Distance: ${data.gps.distanceTo(baseStation).toStringAsFixed(2)} m", ]; @@ -38,4 +41,7 @@ class PositionMetrics extends Metrics { super.update(value); models.sockets.mars.sendMessage(MarsCommand(rover: value.gps)); } + + /// The angle to orient the rover on the top-down map. + double get angle => data.orientation.z; } diff --git a/lib/src/data/protobuf.dart b/lib/src/data/protobuf.dart index 6f481a780..f54ea2dba 100644 --- a/lib/src/data/protobuf.dart +++ b/lib/src/data/protobuf.dart @@ -159,7 +159,7 @@ extension AutonomyStateUtils on AutonomyState { /// The human-readable name of the task. String get humanName { switch (this) { - case AutonomyState.AUTONOMY_STATE_UNDEFINED: return ""; + case AutonomyState.AUTONOMY_STATE_UNDEFINED: return "Disabled"; case AutonomyState.PATHING: return "Calculating path..."; case AutonomyState.APPROACHING: return "Approaching destination"; case AutonomyState.AT_DESTINATION: return "Arrived at destination"; diff --git a/lib/src/models/view/autonomy.dart b/lib/src/models/view/autonomy.dart index 860ca4e41..da7500160 100644 --- a/lib/src/models/view/autonomy.dart +++ b/lib/src/models/view/autonomy.dart @@ -14,7 +14,9 @@ enum AutonomyCell { /// This cell is along the rover's path to its destination. path, /// This cell is traversable but otherwise not of interest. - empty + empty, + /// THis cell was manually marked as a point of interest. + marker, } /// Like an [Offset] from Flutter, but using integers instead of doubles. @@ -75,6 +77,11 @@ class AutonomyModel with ChangeNotifier { ] ]; + /// A list of markers maanually 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(); + /// The rover's current position. GpsCoordinates get roverPosition => models.rover.metrics.position.data.gps; @@ -84,14 +91,17 @@ class AutonomyModel with ChangeNotifier { /// The grid of size [gridSize] with the rover in the center, ready to draw on the UI. List> get grid { final result = empty; - markCell(result, roverPosition, AutonomyCell.rover); markCell(result, data.destination, AutonomyCell.destination); + markCell(result, roverPosition, AutonomyCell.rover); for (final obstacle in data.obstacles) { markCell(result, obstacle, AutonomyCell.obstacle); } for (final path in data.path) { markCell(result, path, AutonomyCell.path); } + for (final marker in markers) { + markCell(result, marker, AutonomyCell.marker); + } return result; } @@ -143,4 +153,18 @@ class AutonomyModel with ChangeNotifier { data.mergeFromMessage(value); notifyListeners(); } + + /// Places the marker in [markerBuilder]. + void placeMarker() { + markers.add(markerBuilder.value); + markerBuilder.clear(); + notifyListeners(); + } + + /// Deletes all the markers in [markers]. + void clearMarkers() { + markers.clear(); + markerBuilder.clear(); + notifyListeners(); + } } diff --git a/lib/src/models/view/builders/autonomy_command.dart b/lib/src/models/view/builders/autonomy_command.dart index 0cbc14233..7ff977799 100644 --- a/lib/src/models/view/builders/autonomy_command.dart +++ b/lib/src/models/view/builders/autonomy_command.dart @@ -5,13 +5,13 @@ import "package:rover_dashboard/models.dart"; class AutonomyCommandBuilder extends ValueBuilder { /// The type of task the rover should complete. AutonomyTask task = AutonomyTask.GPS_ONLY; - /// The latitude of the destination. - final latitude = NumberBuilder(0); - /// The longitude of the destination. - final longitude = NumberBuilder(0); + + /// The view model to edit the [AutonomyCommand.destination]. + final gps = GpsBuilder(); /// The handshake as received by the rover after [submit] is called. AutonomyCommand? _handshake; + /// Whether the dashboard is awaiting a response from the rover. bool isLoading = false; @@ -31,11 +31,11 @@ class AutonomyCommandBuilder extends ValueBuilder { } @override - bool get isValid => latitude.isValid && longitude.isValid; + bool get isValid => gps.isValid; @override AutonomyCommand get value => AutonomyCommand( - destination: GpsCoordinates(latitude: latitude.value, longitude: longitude.value), + destination: gps.value, task: task, ); diff --git a/lib/src/models/view/builders/builder.dart b/lib/src/models/view/builders/builder.dart index 3f66cf87f..96fb421ef 100644 --- a/lib/src/models/view/builders/builder.dart +++ b/lib/src/models/view/builders/builder.dart @@ -70,10 +70,10 @@ class NumberBuilder extends TextBuilder { bool get isInteger => List == List; /// The minimum allowed value. - num? min; + final num? min; /// The maximum allowed value. - num? max; + final num? max; /// Creates a number builder based on an initial value. NumberBuilder(super.value, {this.min, this.max}); @@ -95,6 +95,14 @@ class NumberBuilder extends TextBuilder { } notifyListeners(); } + + /// Clears the value in this builder. + void clear() { + error = null; + value = (isInteger ? 0 : 0.0) as T; + controller.text = value.toString(); + notifyListeners(); + } } /// A specialized [TextBuilder] to handle IP addresses. diff --git a/lib/src/models/view/builders/gps.dart b/lib/src/models/view/builders/gps.dart new file mode 100644 index 000000000..165049a9d --- /dev/null +++ b/lib/src/models/view/builders/gps.dart @@ -0,0 +1,84 @@ +import "package:rover_dashboard/data.dart"; +import "package:rover_dashboard/models.dart"; + +/// The format to enter a GPS coordinate. +/// +/// Internally, all the math is done in [decimal], but [GpsBuilder] supports [degrees] as well. +enum GpsType { + /// Degrees, minutes, seconds format. + degrees, + /// Decimal longitude and latitude. + decimal; + + /// The human-readable name of the GPS type. + String get humanName => switch (this) { + GpsType.degrees => "Degrees", + GpsType.decimal => "Decimal", + }; +} + +/// A [ValueBuilder] to modify a [GpsCoordinates] object in either [GpsType]. +class GpsBuilder extends ValueBuilder { + /// The format to enter a GPS coordinate. + GpsType type = GpsType.decimal; + + /// Longitude in decimal degrees. + final longDecimal = NumberBuilder(0); + /// Latitude in decimal degrees. + final latDecimal = NumberBuilder(0); + + /// Longitude in degrees. + final longDegrees = NumberBuilder(0); + /// Longitude in minutes. + final longMinutes = NumberBuilder(0); + /// Longitude in seconds. + final longSeconds = NumberBuilder(0); + + /// Latitude in degrees. + final latDegrees = NumberBuilder(0); + /// Latitude in minutes. + final latMinutes = NumberBuilder(0); + /// Latitude in seconds. + final latSeconds = NumberBuilder(0); + + @override + List> get otherBuilders => [ + longDecimal, latDecimal, + longDegrees, longMinutes, longSeconds, + latDegrees, latMinutes, latSeconds, + ]; + + @override + bool get isValid => type == GpsType.decimal + ? (latDecimal.isValid && longDecimal.isValid) + : (latDegrees.isValid && longDegrees.isValid && latMinutes.isValid && longMinutes.isValid && latSeconds.isValid && longSeconds.isValid); + + @override + 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), + ), + }; + + /// Clears all the data in these text boxes. + void clear() { + longDecimal.clear(); + longDegrees.clear(); + longMinutes.clear(); + longSeconds.clear(); + + latDecimal.clear(); + latDegrees.clear(); + latMinutes.clear(); + latSeconds.clear(); + notifyListeners(); + } + + /// Updates the [GpsType]. + void updateType(GpsType input) { + type = input; + notifyListeners(); + } +} diff --git a/lib/src/pages/autonomy.dart b/lib/src/pages/autonomy.dart index 56a55f082..cccf0444d 100644 --- a/lib/src/pages/autonomy.dart +++ b/lib/src/pages/autonomy.dart @@ -1,3 +1,4 @@ +import "dart:math"; import "package:flutter/material.dart"; import "package:rover_dashboard/data.dart"; @@ -16,6 +17,7 @@ class AutonomyPage extends StatelessWidget { AutonomyCell.obstacle => Colors.black, AutonomyCell.path => Colors.blueGrey, AutonomyCell.empty => Colors.white, + AutonomyCell.marker => Colors.red, }; @override @@ -27,11 +29,20 @@ class AutonomyPage extends StatelessWidget { const SizedBox(height: 48), for (final row in model.grid) Expanded( child: Row(children: [ - for (final cell in row) Expanded(child: Container( - height: double.infinity, - width: 24, - decoration: BoxDecoration(color: getColor(cell), border: Border.all()), - ),) + for (final cell in row) Expanded( + child: Container( + height: double.infinity, + width: 24, + decoration: BoxDecoration(color: getColor(cell), border: Border.all()), + child: cell != AutonomyCell.rover ? null : ProviderConsumer.value( + value: models.rover.metrics.position, + builder: (position) => Transform.rotate( + angle: position.angle * pi / 180, + child: const Icon(Icons.arrow_upward, size: 24), + ), + ), + ), + ), ],), ), const SizedBox(height: 4), @@ -54,6 +65,10 @@ class AutonomyPage extends StatelessWidget { 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( @@ -65,24 +80,24 @@ class AutonomyPage extends StatelessWidget { onChanged: (value) => model.zoom(value.toInt()), ),), ],), + Row(children: [ + const SizedBox(width: 4), + Text("Place marker: ", style: context.textTheme.titleLarge), + const SizedBox(width: 8), + ElevatedButton.icon(icon: const Icon(Icons.clear), label: const Text("Clear all"), onPressed: model.clearMarkers), + const Spacer(), + GpsEditor(model: model.markerBuilder), + const SizedBox(width: 8), + ElevatedButton(onPressed: model.placeMarker, child: const Text("Place")), + const SizedBox(width: 8), + ],), + const Divider(), ProviderConsumer( create: () => AutonomyCommandBuilder(), builder: (command) => Row(mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: 4), - Text("Next destination: ", style: context.textTheme.titleLarge), + Text("Autonomy: ", style: context.textTheme.titleLarge), const Spacer(), - SizedBox(width: 250, child: NumberEditor( - name: "Longitude", - model: command.longitude, - width: 12, - titleFlex: 1, - ),), - SizedBox(width: 250, child: NumberEditor( - name: "Latitude", - model: command.latitude, - width: 12, - titleFlex: 1, - ),), DropdownEditor( name: "Task type", value: command.task, @@ -93,6 +108,9 @@ class AutonomyPage extends StatelessWidget { onChanged: command.updateTask, humanName: (task) => task.humanName, ), + const SizedBox(width: 16), + GpsEditor(model: command.gps), + const SizedBox(width: 8), ElevatedButton( onPressed: command.isLoading ? null : command.submit, child: const Text("Submit"), @@ -105,14 +123,17 @@ class AutonomyPage extends StatelessWidget { ), Container( color: context.colorScheme.surface, - height: 48, + height: 50, child: Row(children: [ // The header at the top const SizedBox(width: 8), - Text("Autonomy", style: context.textTheme.headlineMedium), + Text("Map", style: context.textTheme.headlineMedium), const Spacer(), - Text("State: ${model.data.state.humanName}", style: context.textTheme.headlineSmall), + Text("Autonomy status", style: context.textTheme.headlineMedium), + const SizedBox(width: 16), + Text("State: ${model.data.state.humanName}.", style: context.textTheme.titleLarge), + const SizedBox(width: 8), + Text("Task: ${model.data.task.humanName}", style: context.textTheme.titleLarge), const VerticalDivider(), - Text("Task: ${model.data.task.humanName}", style: context.textTheme.headlineSmall), const ViewsSelector(currentView: Routes.autonomy), ],), ), diff --git a/lib/src/widgets/atomic/editors.dart b/lib/src/widgets/atomic/editors.dart index f051ed599..046ae455a 100644 --- a/lib/src/widgets/atomic/editors.dart +++ b/lib/src/widgets/atomic/editors.dart @@ -192,3 +192,46 @@ class ColorEditor extends StatelessWidget { ), ); } + +/// A widget to edit a GPS coordinate in degree/minute/seconds or decimal format. +class GpsEditor extends StatelessWidget { + /// The [ValueBuilder] backing this widget. + final GpsBuilder model; + /// Listens to [model] to rebuild the UI. + const GpsEditor({required this.model}); + + @override + Widget build(BuildContext context) => ProviderConsumer.value( + value: model, + builder: (model) => Row(children: [ + DropdownEditor( + name: "Type", + value: model.type, + onChanged: model.updateType, + items: GpsType.values, + humanName: (type) => type.humanName, + ), + const SizedBox(width: 12), + switch (model.type) { + GpsType.degrees => Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Row(children: [ // Longitude + const Text("Longitude:"), + SizedBox(width: 200, child: NumberEditor(name: "Degrees", width: 12, titleFlex: 1, model: model.longDegrees)), + SizedBox(width: 200, child: NumberEditor(name: "Minutes", width: 12, titleFlex: 1, model: model.longMinutes)), + SizedBox(width: 200, child: NumberEditor(name: "Seconds", width: 12, titleFlex: 1, model: model.longSeconds)), + ],), + Row(children: [ // Latitude + const Text("Latitude:"), + SizedBox(width: 200, child: NumberEditor(name: "Degrees", width: 12, titleFlex: 1, model: model.latDegrees)), + SizedBox(width: 200, child: NumberEditor(name: "Minutes", width: 12, titleFlex: 1, model: model.latMinutes)), + SizedBox(width: 200, child: NumberEditor(name: "Seconds", width: 12, titleFlex: 1, model: model.latSeconds)), + ],), + ],), + GpsType.decimal => Row(children: [ + SizedBox(width: 225, child: NumberEditor(name: "Longitude", width: 0, titleFlex: 1, model: model.longDecimal)), + SizedBox(width: 200, child: NumberEditor(name: "Latitude", width: 0, titleFlex: 1, model: model.latDecimal)), + ],), + }, + ],), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index ae551ee5c..ae1a55a8e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: rover_dashboard description: Graphical application for remotely operating the rover. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 2023.5.31+2 +version: 2023.6.1+1 environment: sdk: "^3.0.0" @@ -63,7 +63,7 @@ flutter_launcher_icons: # Builds a Windows .msix App Installer file for the Dashboard. # Run: flutter pub run msix:create msix_config: - msix_version: 2023.5.31.2 + msix_version: 2023.6.1.1 display_name: Dashboard publisher_display_name: Binghamton University Rover Team identity_name: edu.binghamton.rover