From 06a3e4bf261b828b941fddd1c07b75b37608caf1 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:27:17 -0500 Subject: [PATCH 01/17] Rearranged settings --- lib/src/data/settings.dart | 119 +++++++++--------- lib/src/models/data/settings.dart | 9 +- lib/src/models/data/video.dart | 2 +- lib/src/models/data/views.dart | 10 +- .../view/builders/settings_builder.dart | 96 +++++++------- lib/src/models/view/map.dart | 4 +- lib/src/pages/settings.dart | 70 +++++++---- 7 files changed, 162 insertions(+), 148 deletions(-) diff --git a/lib/src/data/settings.dart b/lib/src/data/settings.dart index 8920a4eb1..af544b955 100644 --- a/lib/src/data/settings.dart +++ b/lib/src/data/settings.dart @@ -10,26 +10,6 @@ extension SettingsParser on Json { } } -/// Settings relating to video. -class VideoSettings { - /// How many frames to render per second. - /// - /// This does not affect how many frames are sent by the rover per second. - final int fps; - - /// A const constructor. - const VideoSettings({required this.fps}); - - /// Parses a [VideoSettings] from JSON. - VideoSettings.fromJson(Json? json) : - fps = (json?["fps"] ?? 60) as int; - - /// Serializes these settings in JSON format. - Json toJson() => { - "fps": fps, - }; -} - /// Settings relating to science. class ScienceSettings { /// How many frames to render per second. @@ -140,16 +120,12 @@ class NetworkSettings { /// the tank when it's being used. final SocketInfo tankSocket; - /// The address and port of the Rover's GPS - final SocketInfo marsSocket; - /// Creates a new network settings object. NetworkSettings({ required this.subsystemsSocket, required this.videoSocket, required this.autonomySocket, required this.tankSocket, - required this.marsSocket, required this.connectionTimeout, }); @@ -159,7 +135,6 @@ class NetworkSettings { videoSocket = json?.getSocket("videoSocket") ?? SocketInfo.raw("192.168.1.30", 8002), autonomySocket = json?.getSocket("autonomySocket") ?? SocketInfo.raw("192.168.1.30", 8003), tankSocket = json?.getSocket("tankSocket") ?? SocketInfo.raw("192.168.1.40", 8000), - marsSocket = json?.getSocket("marsSocket") ?? SocketInfo.raw("192.168.1.50", 8006), connectionTimeout = json?["connectionTimeout"] ?? 5; /// Serializes these settings to JSON. @@ -168,34 +143,10 @@ class NetworkSettings { "videoSocket": videoSocket.toJson(), "autonomySocket": autonomySocket.toJson(), "tankSocket": tankSocket.toJson(), - "marsSocket": marsSocket.toJson(), "connectionTimeout": connectionTimeout, }; } -/// Settings relating to autonomy. -class AutonomySettings { - /// The precision of the GPS grid. - /// - /// Since GPS coordinates are decimal values, we divide by this value to get the index of the cell - /// each coordinate belongs to. Smaller sizes means more blocks, but we should be careful that the - /// blocks are big enough to the margin of error of our GPS. This value must be synced with the - /// value in the autonomy program, or else the UI will not be accurate to the rover's logic. - final double blockSize; - - /// A const constructor. - const AutonomySettings({required this.blockSize}); - - /// Parses autonomy settings from a JSON map. - AutonomySettings.fromJson(Json? json) : - blockSize = json?["blockSize"] ?? 1.0; - - /// Serializes these settings to JSON. - Json toJson() => { - "blockSize": blockSize, - }; -} - /// Settings relating to easter eggs. /// /// Implement these! Ask Levi for details. @@ -223,14 +174,63 @@ class EasterEggsSettings { }; } +/// Controls the way the Dashboard views split. +enum SplitMode { + /// Two views are split horizontally, one atop the other. + horizontal("Top and bottom"), + /// Two views are split vertically, side-by-side. + vertical("Side by side"); + + /// The name to show in the UI. + final String humanName; + /// A const constructor. + const SplitMode(this.humanName); +} + +/// Settings related to the dashboard itself, not the rover. +class DashboardSettings { + /// How the Dashboard should split when only two views are present. + final SplitMode splitMode; + + /// The precision of the GPS grid. + /// + /// Since GPS coordinates are decimal values, we divide by this value to get the index of the cell + /// each coordinate belongs to. Smaller sizes means more blocks, but we should be careful that the + /// blocks are big enough to the margin of error of our GPS. This value must be synced with the + /// value in the autonomy program, or else the UI will not be accurate to the rover's logic. + final double mapBlockSize; + + /// How many frames to render per second. + /// + /// This does not affect how many frames are sent by the rover per second. + final int maxFps; + + /// A const constructor. + const DashboardSettings({ + required this.splitMode, + required this.mapBlockSize, + required this.maxFps, + }); + + /// Parses Dashboard settings from JSON. + DashboardSettings.fromJson(Json? json) : + splitMode = SplitMode.values[json?["splitMode"] ?? SplitMode.horizontal.index], + mapBlockSize = json?["mapBlockSize"] ?? 1.0, + maxFps = (json?["maxFps"] ?? 60) as int; + + /// Serializes these settings to JSON. + Json toJson() => { + "splitMode": splitMode.index, + "mapBlockSize": mapBlockSize, + "maxFps": maxFps, + }; +} + /// Contains the settings for running the dashboard and the rover. class Settings { /// Settings for the network, like IP addresses and ports. final NetworkSettings network; - /// Settings for video display. - final VideoSettings video; - /// Settings for easter eggs. /// /// Please, please, please -- do not remove these (Levi Lesches, '25). @@ -242,35 +242,32 @@ class Settings { /// Settings for the science analysis. final ScienceSettings science; - /// Settings for the autonomy display. - final AutonomySettings autonomy; + /// Settings related to the dashboard itself. + final DashboardSettings dashboard; /// A const constructor. const Settings({ required this.network, - required this.video, required this.easterEggs, required this.science, required this.arm, - required this.autonomy, + required this.dashboard, }); /// Initialize settings from Json. Settings.fromJson(Json json) : - autonomy = AutonomySettings.fromJson(json["autonomy"]), network = NetworkSettings.fromJson(json["network"]), - video = VideoSettings.fromJson(json["video"]), easterEggs = EasterEggsSettings.fromJson(json["easterEggs"]), science = ScienceSettings.fromJson(json["science"]), - arm = ArmSettings.fromJson(json["arm"]); + arm = ArmSettings.fromJson(json["arm"]), + dashboard = DashboardSettings.fromJson(json["dashboard"]); /// Converts the data from the settings instance to Json. Json toJson() => { - "autonomy": autonomy.toJson(), "network": network.toJson(), - "video": video.toJson(), "easterEggs": easterEggs.toJson(), "science": science.toJson(), "arm": arm.toJson(), + "dashboard": dashboard.toJson(), }; } diff --git a/lib/src/models/data/settings.dart b/lib/src/models/data/settings.dart index 56b018aea..9370137f9 100644 --- a/lib/src/models/data/settings.dart +++ b/lib/src/models/data/settings.dart @@ -13,18 +13,15 @@ class SettingsModel extends Model { /// The user's arm settings. ArmSettings get arm => all.arm; - /// The user's video settings. - VideoSettings get video => all.video; - /// The user's science settings. ScienceSettings get science => all.science; - /// The user's autonomy settings. - AutonomySettings get autonomy => all.autonomy; - /// The user's easter egg settings. EasterEggsSettings get easterEggs => all.easterEggs; + /// The user's dashboard settings. + DashboardSettings get dashboard => all.dashboard; + @override Future init() async { all = await services.files.readSettings(); diff --git a/lib/src/models/data/video.dart b/lib/src/models/data/video.dart index f41cd2301..cdeb50cd3 100644 --- a/lib/src/models/data/video.dart +++ b/lib/src/models/data/video.dart @@ -80,7 +80,7 @@ class VideoModel extends Model { frameUpdater?.cancel(); frameUpdater = Timer.periodic( - Duration(milliseconds: (1000/models.settings.video.fps).round()), + Duration(milliseconds: (1000/models.settings.dashboard.maxFps).round()), (_) => notifyListeners(), ); } diff --git a/lib/src/models/data/views.dart b/lib/src/models/data/views.dart index f2cf8e9ed..a9da87dbf 100644 --- a/lib/src/models/data/views.dart +++ b/lib/src/models/data/views.dart @@ -112,7 +112,15 @@ class ViewsModel extends Model { ]; @override - Future init() async { } + Future init() async { + models.settings.addListener(notifyListeners); + } + + @override + void dispose() { + models.settings.removeListener(notifyListeners); + super.dispose(); + } /// Replaces the [oldView] with the [newView]. void replaceView(String oldView, DashboardView newView) { diff --git a/lib/src/models/view/builders/settings_builder.dart b/lib/src/models/view/builders/settings_builder.dart index bca4f9995..72c40e549 100644 --- a/lib/src/models/view/builders/settings_builder.dart +++ b/lib/src/models/view/builders/settings_builder.dart @@ -41,27 +41,21 @@ class NetworkSettingsBuilder extends ValueBuilder { /// Since the tank runs multiple programs, the port is discarded and only the address is used. final SocketBuilder tankSocket; - /// The view model representing the [SocketInfo] for the rover. - final SocketBuilder marsSocket; - @override - List get otherBuilders => [dataSocket, videoSocket, autonomySocket, tankSocket, marsSocket]; + List get otherBuilders => [dataSocket, videoSocket, autonomySocket, tankSocket]; /// Creates the view model based on the current [Settings]. NetworkSettingsBuilder(NetworkSettings initial) : dataSocket = SocketBuilder(initial.subsystemsSocket), videoSocket = SocketBuilder(initial.videoSocket), autonomySocket = SocketBuilder(initial.autonomySocket), - tankSocket = SocketBuilder(initial.tankSocket), - marsSocket = SocketBuilder(initial.marsSocket); + tankSocket = SocketBuilder(initial.tankSocket); @override bool get isValid => dataSocket.isValid && videoSocket.isValid && autonomySocket.isValid - && tankSocket.isValid - && marsSocket.isValid; - + && tankSocket.isValid; @override NetworkSettings get value => NetworkSettings( @@ -69,7 +63,6 @@ class NetworkSettingsBuilder extends ValueBuilder { videoSocket: videoSocket.value, autonomySocket: autonomySocket.value, tankSocket: tankSocket.value, - marsSocket: marsSocket.value, connectionTimeout: 5, ); } @@ -148,24 +141,6 @@ class ArmSettingsBuilder extends ValueBuilder{ ); } -/// A [ValueBuilder] that modifies a [VideoSettings]. -class VideoSettingsBuilder extends ValueBuilder { - /// The builder for the FPS count. - final NumberBuilder fps; - - /// Modifies the given [VideoSettings]. - VideoSettingsBuilder(VideoSettings initial) : - fps = NumberBuilder(initial.fps); - - @override - bool get isValid => fps.isValid; - - @override - VideoSettings get value => VideoSettings( - fps: fps.value, - ); -} - /// A [ValueBuilder] that modifies a [ScienceSettings]. class ScienceSettingsBuilder extends ValueBuilder { /// Whether the graphs can scrolls. See [ScienceSettings.scrollableGraphs]. @@ -195,20 +170,39 @@ class ScienceSettingsBuilder extends ValueBuilder { } } -/// A [ValueBuilder] that modifies an [AutonomySettings]. -class AutonomySettingsBuilder extends ValueBuilder { - /// The precision of the GPS grid. See [AutonomySettings.blockSize]. +/// A [ValueBuilder] that modifies a [DashboardSettings]. +class DashboardSettingsBuilder extends ValueBuilder { + /// The builder for the FPS count. + final NumberBuilder fps; + + /// The precision of the GPS grid. See [DashboardSettings.mapBlockSize]. final NumberBuilder blockSize; - /// Fills in the fields with the given [initial] settings. - AutonomySettingsBuilder(AutonomySettings initial) : - blockSize = NumberBuilder(initial.blockSize); + /// How the Dashboard should split when only two views are present. + SplitMode splitMode; - @override - bool get isValid => blockSize.isValid; + /// Modifies the given [DashboardSettings]. + DashboardSettingsBuilder(DashboardSettings initial) : + fps = NumberBuilder(initial.maxFps), + blockSize = NumberBuilder(initial.mapBlockSize), + splitMode = initial.splitMode; - @override - AutonomySettings get value => AutonomySettings(blockSize: blockSize.value); + @override + bool get isValid => fps.isValid && blockSize.isValid; + + @override + DashboardSettings get value => DashboardSettings( + maxFps: fps.value, + mapBlockSize: blockSize.value, + splitMode: splitMode, + ); + + /// Updates the [splitMode] when a new one is selected. + void updateSplitMode(SplitMode? mode) { + if (mode == null) return; + splitMode = mode; + notifyListeners(); + } } /// A [ValueBuilder] that modifies an [EasterEggsSettings]. @@ -254,14 +248,11 @@ class SettingsBuilder extends ValueBuilder { /// The [ArmSettings] view model. final ArmSettingsBuilder arm; - /// The [VideoSettings] view model. - final VideoSettingsBuilder video; - /// The [ScienceSettings] view model. final ScienceSettingsBuilder science; - /// The [AutonomySettings] view model. - final AutonomySettingsBuilder autonomy; + /// The [DashboardSettings] view model. + final DashboardSettingsBuilder dashboard; /// The [EasterEggsSettings] view model. final EasterEggsSettingsBuilder easterEggs; @@ -271,36 +262,33 @@ class SettingsBuilder extends ValueBuilder { /// Modifies the user's settings. SettingsBuilder() : - autonomy = AutonomySettingsBuilder(models.settings.autonomy), network = NetworkSettingsBuilder(models.settings.network), arm = ArmSettingsBuilder(models.settings.arm), - video = VideoSettingsBuilder(models.settings.video), science = ScienceSettingsBuilder(models.settings.science), + dashboard = DashboardSettingsBuilder(models.settings.dashboard), easterEggs = EasterEggsSettingsBuilder(models.settings.easterEggs) { - autonomy.addListener(notifyListeners); network.addListener(notifyListeners); arm.addListener(notifyListeners); - video.addListener(notifyListeners); science.addListener(notifyListeners); + dashboard.addListener(notifyListeners); easterEggs.addListener(notifyListeners); } @override bool get isValid => network.isValid && arm.isValid - && autonomy.isValid - && video.isValid - && science.isValid; + && science.isValid + && dashboard.isValid + && easterEggs.isValid; @override Settings get value => Settings( - autonomy: autonomy.value, network: network.value, - video: video.value, - easterEggs: easterEggs.value, - science: science.value, arm: arm.value, + science: science.value, + dashboard: dashboard.value, + easterEggs: easterEggs.value, ); /// Saves the settings to the device. diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index c20e09142..30b2a6eaa 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -58,6 +58,7 @@ class AutonomyModel with ChangeNotifier { handler: onNewData, ); models.rover.metrics.position.addListener(recenterRover); + models.settings.addListener(notifyListeners); // Force the initial update, even with no new data. recenterRover(); onNewData(AutonomyData()); @@ -66,6 +67,7 @@ class AutonomyModel with ChangeNotifier { @override void dispose() { models.messages.removeHandler(AutonomyData().messageName); + models.settings.removeListener(notifyListeners); models.rover.metrics.position.removeListener(recenterRover); super.dispose(); } @@ -108,7 +110,7 @@ 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.autonomy.blockSize).round(); + 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]. /// diff --git a/lib/src/pages/settings.dart b/lib/src/pages/settings.dart index b88e70282..df265bff5 100644 --- a/lib/src/pages/settings.dart +++ b/lib/src/pages/settings.dart @@ -32,7 +32,7 @@ class ValueEditor extends StatelessWidget { name, style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.start, - ), + ), ), const SizedBox(height: 4), ...children, @@ -74,17 +74,6 @@ class SettingsPage extends StatelessWidget { ], ), const Divider(), - ValueEditor( - name: "Video settings", - children: [ - NumberEditor( - name: "Frames per second", - subtitle: "This does not affect the rover's cameras. Useful for limiting the CPU of the dashboard", - model: model.video.fps, - ), - ], - ), - const Divider(), ValueEditor( name: "Arm settings", children: [ @@ -120,17 +109,41 @@ class SettingsPage extends StatelessWidget { ], ), const Divider(), - ValueEditor( - name: "Autonomy settings", - children: [ - NumberEditor( - name: "Block size", - subtitle: "The precision of the GPS grid", - model: model.autonomy.blockSize, - ), - ], - ), - const Divider(), + ValueEditor( + name: "Dashboard Settings", + children: [ + NumberEditor( + name: "Frames per second", + subtitle: "This does not affect the rover's cameras. Useful for limiting the CPU of the dashboard", + model: model.dashboard.fps, + ), + NumberEditor( + name: "Block size", + subtitle: "The precision of the GPS grid", + model: model.dashboard.blockSize, + ), + Row(children: [ + const SizedBox( + width: 200, + child: ListTile( + title: Text("Split mode"), + ), + ), + const Spacer(), + DropdownMenu( + initialSelection: model.dashboard.splitMode, + onSelected: model.dashboard.updateSplitMode, + dropdownMenuEntries: [ + for (final value in SplitMode.values) DropdownMenuEntry( + value: value, + label: value.humanName, + ), + ], + ), + ],), + ], + ), + const Divider(), ValueEditor( name: "Easter eggs", children: [ @@ -149,7 +162,16 @@ class SettingsPage extends StatelessWidget { const Divider(), Text("Misc", style: Theme.of(context).textTheme.titleLarge), ListTile( - title: const Text("Open logs"), + title: const Text("Adjust throttle"), + subtitle: const Text("Sets the max speed on the rover"), + trailing: const Icon(Icons.speed), + onTap: () => showDialog( + context: context, + builder: (_) => ThrottleEditor(), + ), + ), + ListTile( + title: const Text("Open session output"), subtitle: const Text("Opens all files created by this session"), trailing: const Icon(Icons.launch), onTap: () => launchUrl(services.files.loggingDir.uri), From de66274f835119c3cd847df4e070a827773b6a71 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:27:59 -0500 Subject: [PATCH 02/17] Added ThrottleEditor and ThrottleBuilder --- lib/models.dart | 1 + lib/src/models/rover/controls/drive.dart | 22 ++++++------- lib/src/models/view/builders/throttle.dart | 29 +++++++++++++++++ lib/src/widgets/atomic/editors.dart | 37 ++++++++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 lib/src/models/view/builders/throttle.dart diff --git a/lib/models.dart b/lib/models.dart index 8a7ae5780..5647eff8d 100644 --- a/lib/models.dart +++ b/lib/models.dart @@ -52,6 +52,7 @@ export "src/models/view/builders/science_command.dart"; export "src/models/view/builders/builder.dart"; export "src/models/view/builders/color_builder.dart"; export "src/models/view/builders/settings_builder.dart"; +export "src/models/view/builders/throttle.dart"; export "src/models/view/builders/timer_builder.dart"; export "src/models/view/builders/video_builder.dart"; diff --git a/lib/src/models/rover/controls/drive.dart b/lib/src/models/rover/controls/drive.dart index cc3922327..ab9e15d44 100644 --- a/lib/src/models/rover/controls/drive.dart +++ b/lib/src/models/rover/controls/drive.dart @@ -16,21 +16,21 @@ class DriveControls extends RoverControls { @override List parseInputs(GamepadState state) => [ - // Adjust throttle - if (state.dpadUp) updateThrottle(throttleIncrement) - else if (state.dpadDown) updateThrottle(-throttleIncrement), + // // Adjust throttle + // if (state.dpadUp) updateThrottle(throttleIncrement) + // else if (state.dpadDown) updateThrottle(-throttleIncrement), // Adjust wheels DriveCommand(setLeft: true, left: state.normalLeftY), DriveCommand(setRight: true, right: -1*state.normalRightY), // More intuitive controls - if (state.normalShoulder != 0) ...[ - DriveCommand(setLeft: true, left: state.normalShoulder), - DriveCommand(setRight: true, right: state.normalShoulder), - ], - if (state.normalTrigger != 0) ...[ - DriveCommand(setLeft: true, left: state.normalTrigger), - DriveCommand(setRight: true, right: -1 * state.normalTrigger), - ], + // if (state.normalShoulder != 0) ...[ + // DriveCommand(setLeft: true, left: state.normalShoulder), + // DriveCommand(setRight: true, right: state.normalShoulder), + // ], + // if (state.normalTrigger != 0) ...[ + // DriveCommand(setLeft: true, left: state.normalTrigger), + // DriveCommand(setRight: true, right: -1 * state.normalTrigger), + // ], ]; /// Updates the throttle by [throttleIncrement], clamping at [0, 1]. diff --git a/lib/src/models/view/builders/throttle.dart b/lib/src/models/view/builders/throttle.dart new file mode 100644 index 000000000..f9056b0b0 --- /dev/null +++ b/lib/src/models/view/builders/throttle.dart @@ -0,0 +1,29 @@ +import "package:flutter/foundation.dart"; + +import "package:rover_dashboard/data.dart"; +import "package:rover_dashboard/models.dart"; + +/// A view model to allow the user to edit a throttle value and send it to the rover. +class ThrottleBuilder with ChangeNotifier { + /// A [NumberBuilder] to modify the throttle value. + final controller = NumberBuilder(0, min: 0, max: 1); + + /// Whether the throttle is valid. + bool get isValid => controller.isValid; + /// Whether the throttle command is still sending. + bool isLoading = false; + /// The error with the throttle, if any. + String? errorText; + + /// Saves the throttle to the rover. Does not perform a handshake. + Future save() async { + isLoading = true; + notifyListeners(); + final throttle = controller.value; + final command = DriveCommand(setThrottle: true, throttle: throttle); + models.messages.sendMessage(command); + await Future.delayed(const Duration(milliseconds: 200)); + isLoading = false; + notifyListeners(); + } +} diff --git a/lib/src/widgets/atomic/editors.dart b/lib/src/widgets/atomic/editors.dart index 03d702f85..5016224fd 100644 --- a/lib/src/widgets/atomic/editors.dart +++ b/lib/src/widgets/atomic/editors.dart @@ -272,3 +272,40 @@ class GpsEditor extends StatelessWidget { ), ); } + +/// An [AlertDialog] to prompt the user for a throttle value and send it to the rover. +class ThrottleEditor extends ReactiveWidget { + @override + ThrottleBuilder createModel() => ThrottleBuilder(); + + @override + Widget build(BuildContext context, ThrottleBuilder model) => AlertDialog( + title: const Text("Adjust throttle"), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + NumberEditor(name: "Throttle", model: model.controller), + const SizedBox(height: 12), + if (model.errorText != null) Text( + model.errorText!, + style: const TextStyle(color: Colors.red), + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: !model.isValid || model.isLoading ? null : () async { + await model.save(); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + child: const Text("Save"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + ], + ); +} From 7f502d165b7d5558671cfb337a2b55800fe55b98 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:28:14 -0500 Subject: [PATCH 03/17] Made views resizable --- lib/src/widgets/navigation/views.dart | 120 +++++++++++++++++++++----- pubspec.lock | 8 ++ pubspec.yaml | 1 + 3 files changed, 106 insertions(+), 23 deletions(-) diff --git a/lib/src/widgets/navigation/views.dart b/lib/src/widgets/navigation/views.dart index dbb977c2d..dbbd3b8a9 100644 --- a/lib/src/widgets/navigation/views.dart +++ b/lib/src/widgets/navigation/views.dart @@ -1,5 +1,7 @@ import "package:flutter/material.dart"; +import "package:flutter_resizable_container/flutter_resizable_container.dart"; +import "package:rover_dashboard/data.dart"; import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/widgets.dart"; @@ -8,31 +10,103 @@ class ViewsWidget extends StatelessWidget { /// A const constructor. const ViewsWidget(); - /// Renders the view at the given [index] in [ViewsModel.views]. - Widget getView(BuildContext context, int index) => Expanded(child: Container( - decoration: BoxDecoration(border: Border.all(width: 3)), - child: models.views.views[index].builder(context), - ),); - @override Widget build(BuildContext context) => ProviderConsumer.value( value: models.views, - builder: (model) => Column(children: [ - Expanded( - child: Row(children: [ - if (model.views.isNotEmpty) getView(context, 0), - if (model.views.length >= 3) getView(context, 1), - ], - ),), - if (model.views.length >= 2) Expanded( - child: Row(children: [ - if (model.views.length >= 2) - // Put the 2nd view on the bottom row or the upper left corner - if (model.views.length >= 3) getView(context, 2) - else getView(context, 1), - if (model.views.length >= 4) getView(context, 3), - ],), - ), - ],), + builder: (model) => LayoutBuilder( + builder: (context, constraints) => switch (model.views.length) { + 1 => Column(children: [Expanded(child: models.views.views.first.builder(context))]), + 2 => ResizableContainer( + direction: switch (models.settings.dashboard.splitMode) { + SplitMode.horizontal => Axis.vertical, + SplitMode.vertical => Axis.horizontal, + }, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[0].builder(context), + ), + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[1].builder(context), + ), + ], + ), + 3 => ResizableContainer( + direction: Axis.vertical, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: ResizableContainer( + direction: Axis.horizontal, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[0].builder(context), + ), + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[1].builder(context), + ), + ], + ), + ), + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[2].builder(context), + ), + ], + ), + 4 => ResizableContainer( + direction: Axis.vertical, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: ResizableContainer( + direction: Axis.horizontal, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[0].builder(context), + ), + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[1].builder(context), + ), + ], + ), + ), + ResizableChildData( + startingRatio: 0.5, + child: ResizableContainer( + direction: Axis.horizontal, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[2].builder(context), + ), + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[3].builder(context), + ), + ], + ), + ), + ], + ), + _ => throw StateError("Too many views: ${model.views.length}"), + },), ); } diff --git a/pubspec.lock b/pubspec.lock index 866ab7bcf..0fd4cee01 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -191,6 +191,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.17" + flutter_resizable_container: + dependency: "direct main" + description: + name: flutter_resizable_container + sha256: d3f11e7b88271a00282804ee528fbd90790b2b2db340f9ff76b0504c4cdfa297 + url: "https://pub.dev" + source: hosted + version: "0.3.0" flutter_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index c615eaeb6..506f98277 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: yaml_writer: ^2.0.0 burt_network: git: https://github.com/BinghamtonRover/Networking.git + flutter_resizable_container: ^0.3.0 # Prefer to use `flutter pub add --dev packageName` rather than modify this section by hand. dev_dependencies: From 6040198c24311323e8a284ab1fe62787fcce9053 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:38:57 -0500 Subject: [PATCH 04/17] Upgraded packages --- build.yaml | 19 ------------------- pubspec.lock | 44 ++++++++++---------------------------------- pubspec.yaml | 18 +++++------------- 3 files changed, 15 insertions(+), 66 deletions(-) delete mode 100644 build.yaml diff --git a/build.yaml b/build.yaml deleted file mode 100644 index 7baebc242..000000000 --- a/build.yaml +++ /dev/null @@ -1,19 +0,0 @@ -targets: - $default: - sources: - - $package$ - - lib/$lib$ - - Protobuf/**.proto - builders: - protoc_builder: - options: - # Directory which is treated as the root of all Protobuf files. - # (Default: "proto/") - root_dir: "Protobuf/" - # Include paths given to the Protobuf compiler during compilation. - # (Default: ["proto/"]) - proto_paths: - - "Protobuf/" - # The root directory for generated Dart output files. - # (Default: "lib/src/proto") - out_dir: "lib/generated" diff --git a/pubspec.lock b/pubspec.lock index 0fd4cee01..661e24401 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "2.4.2" async: - dependency: "direct main" + dependency: transitive description: name: async sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" @@ -158,10 +158,10 @@ packages: dependency: "direct main" description: name: fl_chart - sha256: b5e2b0f13d93f8c532b5a2786bfb44580de1f50b927bf95813fa1af617e9caf8 + sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d" url: "https://pub.dev" source: hosted - version: "0.66.1" + version: "0.66.2" flutter: dependency: "direct main" description: flutter @@ -245,10 +245,10 @@ packages: dependency: transitive description: name: image - sha256: "49a0d4b0c12402853d3f227fe7c315601b238d126aa4caa5dbb2dcf99421aa4a" + sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" url: "https://pub.dev" source: hosted - version: "4.1.6" + version: "4.1.7" js: dependency: transitive description: @@ -313,14 +313,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.16.7" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" package_config: dependency: transitive description: @@ -441,14 +433,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - provider: - dependency: "direct main" - description: - name: provider - sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" - url: "https://pub.dev" - source: hosted - version: "6.1.1" pub_semver: dependency: transitive description: @@ -538,10 +522,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "507dc655b1d9cb5ebc756032eb785f114e415f91557b73bf60b7e201dfedeb2f" + sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 url: "https://pub.dev" source: hosted - version: "6.2.2" + version: "6.3.0" url_launcher_ios: dependency: transitive description: @@ -570,10 +554,10 @@ packages: dependency: transitive description: name: url_launcher_platform_interface - sha256: a932c3a8082e118f80a475ce692fde89dc20fddb24c57360b96bc56f7035de1f + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" url_launcher_web: dependency: transitive description: @@ -647,21 +631,13 @@ packages: source: hosted version: "6.5.0" yaml: - dependency: "direct main" + dependency: transitive description: name: yaml sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted version: "3.1.2" - yaml_writer: - dependency: "direct main" - description: - name: yaml_writer - sha256: f182931a598f9a3fd29ff528f0caab98fffa713583e30c12c7a27ce0b66c1308 - url: "https://pub.dev" - source: hosted - version: "2.0.0" sdks: dart: ">=3.2.0 <4.0.0" flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index 506f98277..ef5bf6927 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,23 +8,19 @@ environment: # Prefer to use `flutter pub add packageName` rather than modify this section by hand. dependencies: - async: ^2.11.0 - file_picker: ^6.1.1 - fl_chart: ^0.66.0 flutter: sdk: flutter + burt_network: + git: https://github.com/BinghamtonRover/Networking.git + file_picker: ^6.1.1 + fl_chart: ^0.66.0 flutter_libserialport: ^0.3.0 package_info_plus: ^5.0.1 + flutter_resizable_container: ^0.3.0 path_provider: ^2.0.14 protobuf: ^3.0.0 - provider: ^6.0.5 url_launcher: ^6.1.10 win32_gamepad: ^1.0.3 - yaml: ^3.1.1 - yaml_writer: ^2.0.0 - burt_network: - git: https://github.com/BinghamtonRover/Networking.git - flutter_resizable_container: ^0.3.0 # Prefer to use `flutter pub add --dev packageName` rather than modify this section by hand. dev_dependencies: @@ -35,10 +31,6 @@ dev_dependencies: msix: ^3.9.1 very_good_analysis: ^5.0.0+1 -# Do not modify this section unless you know what it does and why you need it. -# If you do need to override any dependencies, document which ones, which versions, and why. -# dependency_overrides: - # Generates icons for the given platforms # Run: flutter pub run icons_launcher:create icons_launcher: From 3794293046f49b25d434cb6917a0c5eb83c9b070 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:40:10 -0500 Subject: [PATCH 05/17] Removed ProviderConsumer and updated ReactiveWidget --- lib/src/pages/home.dart | 74 ++++--------------- .../widgets/generic/provider_consumer.dart | 39 ---------- lib/src/widgets/generic/reactive_widget.dart | 47 +++++++++--- lib/widgets.dart | 1 - 4 files changed, 53 insertions(+), 108 deletions(-) delete mode 100644 lib/src/widgets/generic/provider_consumer.dart diff --git a/lib/src/pages/home.dart b/lib/src/pages/home.dart index 37cc0841c..030057428 100644 --- a/lib/src/pages/home.dart +++ b/lib/src/pages/home.dart @@ -5,65 +5,23 @@ import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/pages.dart"; import "package:rover_dashboard/widgets.dart"; -/// A widget to view timer -/// Can also stop and start timer -class Timer extends StatelessWidget { - @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: models.home.mission, - builder: (model) => (model.title == null) - ? Container() - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("${model.title}: ", - style: context.textTheme.headlineSmall!.copyWith(color: context.colorScheme.onPrimary), - ), - const SizedBox(width: 4), - AnimatedScale( - scale: (model.underMin) && (model.timeLeft.inSeconds.isEven) ? 1.2 : 1, - duration: const Duration(milliseconds: 750), - child: Text(model.timeLeft.toString().split(".").first.padLeft(8, "0"), - style: model.underMin - ? context.textTheme.headlineSmall!.copyWith( - color: context.colorScheme.error, - fontWeight: FontWeight.bold, - ) - : context.textTheme.headlineSmall!.copyWith(color: context.colorScheme.onPrimary), - ), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: model.isPaused ? model.resume : model.pause, - child: model.isPaused ? const Text("Resume") : const Text("Pause"), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: model.cancel, - child: const Text("Cancel"), - ), - ], - ), - ); -} - /// A widget to switch between tank and rover modes. -class SocketSwitcher extends StatelessWidget { +class SocketSwitcher extends ReusableReactiveWidget { + /// A constructor for this widget. + SocketSwitcher() : super(models.sockets); + @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: models.sockets, - builder: (model) => DropdownButton( - value: model.rover, - onChanged: model.setRover, - focusNode: FocusNode(), - items: [ - for (final type in RoverType.values) DropdownMenuItem( - value: type, - child: Text(type.humanName), - ), - ], - ), - ); + Widget build(BuildContext context, Sockets model) => DropdownButton( + value: model.rover, + onChanged: model.setRover, + focusNode: FocusNode(), + items: [ + for (final type in RoverType.values) DropdownMenuItem( + value: type, + child: Text(type.humanName), + ), + ], + ); } /// The main dashboard page. @@ -84,7 +42,7 @@ class HomePageState extends State{ appBar: AppBar( automaticallyImplyLeading: false, title: Text("Dashboard v${models.home.version ?? ''}"), - flexibleSpace: Center(child: Timer()), + flexibleSpace: Center(child: TimerWidget()), actions: [ SocketSwitcher(), IconButton( diff --git a/lib/src/widgets/generic/provider_consumer.dart b/lib/src/widgets/generic/provider_consumer.dart deleted file mode 100644 index 1f6d6042e..000000000 --- a/lib/src/widgets/generic/provider_consumer.dart +++ /dev/null @@ -1,39 +0,0 @@ -import "package:flutter/material.dart"; -import "package:provider/provider.dart"; - -/// A [Provider] and a [Consumer], wrapped in one. -/// -/// To use, pass in a [create] function to create a new [ChangeNotifier], then pass a [builder] to -/// build the UI based on the data in the model. To not dispose, use the [value] constructror. -class ProviderConsumer extends StatelessWidget { - /// The value, if using [ChangeNotifierProvider.value]. - final T? value; - - /// A function to create the [ChangeNotifier] - final T Function()? create; - - /// A function to build the UI based on the [ChangeNotifier]. - final Widget Function(T) builder; - - /// A widget that rebuilds when the underlying data changes. - const ProviderConsumer({ - required this.create, - required this.builder, - }) : value = null; - - /// Analagous to [Provider.value]. - const ProviderConsumer.value({ - required this.value, - required this.builder, - }) : create = null; - - /// The [Consumer] for this model. - Widget get consumer => Consumer( - builder: (context, model, _) => builder(model), - ); - - @override - Widget build(BuildContext context) => value == null - ? ChangeNotifierProvider(create: (_) => create!(), child: consumer) - : ChangeNotifierProvider.value(value: value!, child: consumer); -} diff --git a/lib/src/widgets/generic/reactive_widget.dart b/lib/src/widgets/generic/reactive_widget.dart index df7bf62db..051cbac26 100644 --- a/lib/src/widgets/generic/reactive_widget.dart +++ b/lib/src/widgets/generic/reactive_widget.dart @@ -1,25 +1,46 @@ import "package:flutter/material.dart"; +abstract class ReactiveWidgetInterface extends StatefulWidget { + const ReactiveWidgetInterface({super.key}); + T createModel(); + bool get shouldDispose; + + @override + ReactiveWidgetState createState() => ReactiveWidgetState(); + + /// Builds the UI according to the state in [model]. + Widget build(BuildContext context, T model); + + @mustCallSuper + void didUpdateWidget(covariant ReactiveWidgetInterface oldWidget, T model) { } +} + /// A widget that listens to a [ChangeNotifier] and rebuilds when the model updates. -abstract class ReactiveWidget extends StatefulWidget { - /// Whether the associated model should be disposed after this widget is. - final bool shouldDispose; - +abstract class ReactiveWidget extends ReactiveWidgetInterface { /// A const constructor. - const ReactiveWidget({this.shouldDispose = true}); + const ReactiveWidget({super.key}); /// A function to create or find the model. This function will only be called once. + @override T createModel(); - /// Builds the UI according to the state in [model]. - Widget build(BuildContext context, T model); + @override + bool get shouldDispose => true; +} - @override - ReactiveWidgetState createState() => ReactiveWidgetState(); +abstract class ReusableReactiveWidget extends ReactiveWidgetInterface { + final T model; + const ReusableReactiveWidget(this.model); + + @override + T createModel() => model; + + @override + bool get shouldDispose => false; } /// A state for [ReactiveWidget] that manages the [model]. -class ReactiveWidgetState extends State>{ +class ReactiveWidgetState extends State>{ /// The model to listen to. late final T model; @@ -37,6 +58,12 @@ class ReactiveWidgetState extends State oldWidget) { + widget.didUpdateWidget(oldWidget, model); + super.didUpdateWidget(oldWidget); + } + /// Updates the UI when [model] updates. void listener() => setState(() {}); diff --git a/lib/widgets.dart b/lib/widgets.dart index 4ac72a750..cb4bc7518 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -22,7 +22,6 @@ export "src/widgets/atomic/editors.dart"; export "src/widgets/atomic/video_feed.dart"; export "src/widgets/generic/gamepad.dart"; -export "src/widgets/generic/provider_consumer.dart"; export "src/widgets/generic/reactive_widget.dart"; export "src/widgets/navigation/footer.dart"; From d284cc09c66f42a081958ab128fa4028aa8b91e0 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:41:35 -0500 Subject: [PATCH 06/17] Upgraded HomePage to ReactiveWidget --- lib/src/widgets/generic/timer.dart | 50 ++++++++++++++++++++++++++++++ lib/widgets.dart | 1 + 2 files changed, 51 insertions(+) create mode 100644 lib/src/widgets/generic/timer.dart diff --git a/lib/src/widgets/generic/timer.dart b/lib/src/widgets/generic/timer.dart new file mode 100644 index 000000000..a114a3c13 --- /dev/null +++ b/lib/src/widgets/generic/timer.dart @@ -0,0 +1,50 @@ +import "package:flutter/material.dart"; + +import "package:rover_dashboard/models.dart"; +import "package:rover_dashboard/widgets.dart"; + +/// A widget to view timer +/// Can also stop and start timer +class TimerWidget extends ReusableReactiveWidget { + /// Creates a new Timer widget + TimerWidget() : super(models.home.mission); + + /// Gets the text style for the timer. + TextStyle getStyle(BuildContext context, MissionTimer model) => model.underMin + ? context.textTheme.headlineSmall!.copyWith( + color: context.colorScheme.error, + fontWeight: FontWeight.bold, + ) + : context.textTheme.headlineSmall!.copyWith(color: context.colorScheme.onPrimary); + + @override + Widget build(BuildContext context, MissionTimer model) => model.title == null + ? Container() + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("${model.title}: ", + style: context.textTheme.headlineSmall!.copyWith(color: context.colorScheme.onPrimary), + ), + const SizedBox(width: 4), + AnimatedScale( + scale: (model.underMin) && (model.timeLeft.inSeconds.isEven) ? 1.2 : 1, + duration: const Duration(milliseconds: 750), + child: Text( + model.timeLeft.toString().split(".").first.padLeft(8, "0"), + style: getStyle(context, model), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: model.isPaused ? model.resume : model.pause, + child: model.isPaused ? const Text("Resume") : const Text("Pause"), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: model.cancel, + child: const Text("Cancel"), + ), + ], + ); +} diff --git a/lib/widgets.dart b/lib/widgets.dart index cb4bc7518..8451e164a 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -23,6 +23,7 @@ export "src/widgets/atomic/video_feed.dart"; export "src/widgets/generic/gamepad.dart"; export "src/widgets/generic/reactive_widget.dart"; +export "src/widgets/generic/timer.dart"; export "src/widgets/navigation/footer.dart"; export "src/widgets/navigation/sidebar.dart"; From eda8a208125f0ffa832057e4457bde50bf934618 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:45:22 -0500 Subject: [PATCH 07/17] Upgraded LogsPage to ReusableReactiveWidget --- lib/src/pages/logs.dart | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/lib/src/pages/logs.dart b/lib/src/pages/logs.dart index 05730bf01..8973f4e5a 100644 --- a/lib/src/pages/logs.dart +++ b/lib/src/pages/logs.dart @@ -7,14 +7,12 @@ import "package:rover_dashboard/widgets.dart"; /// A widget to show the options for the logs page. /// /// This is separate from the logs display so that the menu doesn't flicker when new logs arrive. -class LogsOptions extends ReactiveWidget { - /// The view model that contains the options for the logs page. +class LogsOptions extends ReusableReactiveWidget { + /// The view model for the whole page. final LogsViewModel viewModel; - /// Listens to the view model without disposing it. - const LogsOptions(this.viewModel) : super(shouldDispose: false); - @override - LogsOptionsViewModel createModel() => viewModel.options; + /// Listens to the view model without disposing it. + LogsOptions(this.viewModel) : super(viewModel.options); @override Widget build(BuildContext context, LogsOptionsViewModel model) => Wrap( @@ -142,15 +140,10 @@ class LogsState extends State { /// The widget that actually contains the logs for the page. /// /// This is a separate widget to prevent updating the rest of the page when new logs come in. -class LogsBody extends ReactiveWidget { - /// The view model for this page. - final LogsViewModel model; +class LogsBody extends ReusableReactiveWidget { /// Listens to the given view model. - const LogsBody(this.model) : super(shouldDispose: false); + const LogsBody(super.model); - @override - LogsViewModel createModel() => model; - @override Widget build(BuildContext context, LogsViewModel model) => model.logs.isEmpty ? const Center(child: Text("No logs yet")) From 59f1c99575d88929e26b595406a1378b850b20fa Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:51:32 -0500 Subject: [PATCH 08/17] Upgraded MapPage to ReactiveWidget --- lib/src/models/view/map.dart | 3 + lib/src/pages/map.dart | 214 +++++++------------ lib/src/widgets/atomic/autonomy_command.dart | 60 ++++++ lib/widgets.dart | 1 + 4 files changed, 145 insertions(+), 133 deletions(-) create mode 100644 lib/src/widgets/atomic/autonomy_command.dart diff --git a/lib/src/models/view/map.dart b/lib/src/models/view/map.dart index 30b2a6eaa..f801c5730 100644 --- a/lib/src/models/view/map.dart +++ b/lib/src/models/view/map.dart @@ -88,6 +88,9 @@ class AutonomyModel with ChangeNotifier { /// The rover's current position. GpsCoordinates get roverPosition => models.rover.metrics.position.data.gps; + /// The rover's heading + double get roverHeading => models.rover.metrics.position.angle; + /// The autonomy data as received from the rover. AutonomyData data = AutonomyData(); diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index e3cd6c628..595142cdb 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -9,7 +9,7 @@ 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 StatelessWidget { +class MapPage extends ReactiveWidget { /// Gets the color for a given [AutonomyCell]. Color? getColor(AutonomyCell cell) => switch(cell) { AutonomyCell.rover => Colors.blue, @@ -41,143 +41,91 @@ class MapPage extends StatelessWidget { ), ); - /// Opens a dialog to prompt the user to create an [AutonomyCommand] and sends it to the rover. - void createTask(BuildContext context, AutonomyCommandBuilder command) => showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text("Create a new Task"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - DropdownEditor( - name: "Task type", - value: command.task, - items: [ - for (final task in AutonomyTask.values) - if (task != AutonomyTask.AUTONOMY_TASK_UNDEFINED) task, - ], - onChanged: command.updateTask, - humanName: (task) => task.humanName, - ), - GpsEditor(model: command.gps), - ], - ), - actions: [ - TextButton(child: const Text("Cancel"), onPressed: () => Navigator.of(context).pop()), - ElevatedButton( - onPressed: command.isLoading ? null : () { command.submit(); Navigator.of(context).pop(); }, - child: const Text("Submit"), - ), - ], - ), - ); + @override + AutonomyModel createModel() => AutonomyModel(); @override - Widget build(BuildContext context) => ProviderConsumer( - create: AutonomyModel.new, - builder: (model) => Stack(children: [ - Column(children: [ - const SizedBox(height: 48), - for (final row in model.grid.reversed) 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()), - 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), - ), - ), + Widget build(BuildContext context, AutonomyModel model) => Stack(children: [ + Column(children: [ + const SizedBox(height: 48), + for (final row in model.grid.reversed) 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()), + child: cell != AutonomyCell.rover ? null : Transform.rotate( + angle: -model.roverHeading * pi / 180, + child: const Icon(Icons.arrow_upward, size: 24), ), ), - ],), - ), - const SizedBox(height: 4), - 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()), - ),), - ],), - 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.clear), label: const Text("Clear all"), onPressed: model.clearMarkers), - const Spacer(), - ProviderConsumer( - create: AutonomyCommandBuilder.new, - builder: (command) => 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: () => createTask(context, command), - ), - const SizedBox(width: 8), - ElevatedButton( - style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.red)), - onPressed: command.abort, - child: const Text("ABORT"), - ), - ],), ), - const SizedBox(width: 8), ],), - const SizedBox(height: 4), + ), + const SizedBox(height: 4), + 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()), + ),), + ],), + 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.clear), label: const Text("Clear all"), onPressed: model.clearMarkers), + const Spacer(), + AutonomyCommandEditor(), + const SizedBox(width: 8), + ],), + 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), + const Spacer(), + Text("Autonomy status: ${model.data.state.humanName}, ${model.data.task.humanName}", style: context.textTheme.headlineSmall), + const VerticalDivider(), + const ViewsSelector(currentView: Routes.autonomy), ],), - 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), - const Spacer(), - Text("Autonomy status: ${model.data.state.humanName}, ${model.data.task.humanName}", style: context.textTheme.headlineSmall), - const VerticalDivider(), - const ViewsSelector(currentView: Routes.autonomy), - ],), - ), - ],), - ); + ), + ],); } diff --git a/lib/src/widgets/atomic/autonomy_command.dart b/lib/src/widgets/atomic/autonomy_command.dart new file mode 100644 index 000000000..acffafe4e --- /dev/null +++ b/lib/src/widgets/atomic/autonomy_command.dart @@ -0,0 +1,60 @@ +import "package:flutter/material.dart"; + +import "package:rover_dashboard/data.dart"; +import "package:rover_dashboard/models.dart"; +import "package:rover_dashboard/widgets.dart"; + +/// A widget to edit an [AutonomyCommand]. +class AutonomyCommandEditor extends ReactiveWidget { + @override + AutonomyCommandBuilder createModel() => AutonomyCommandBuilder(); + + /// Opens a dialog to prompt the user to create an [AutonomyCommand] and sends it to the rover. + void createTask(BuildContext context, AutonomyCommandBuilder command) => showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text("Create a new Task"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownEditor( + name: "Task type", + value: command.task, + items: [ + for (final task in AutonomyTask.values) + if (task != AutonomyTask.AUTONOMY_TASK_UNDEFINED) task, + ], + onChanged: command.updateTask, + humanName: (task) => task.humanName, + ), + GpsEditor(model: command.gps), + ], + ), + actions: [ + TextButton(child: const Text("Cancel"), onPressed: () => Navigator.of(context).pop()), + ElevatedButton( + onPressed: command.isLoading ? null : () { command.submit(); Navigator.of(context).pop(); }, + child: const Text("Submit"), + ), + ], + ), + ); + + @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: () => createTask(context, model), + ), + const SizedBox(width: 8), + ElevatedButton( + style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.red)), + onPressed: model.abort, + child: const Text("ABORT"), + ), + ],); +} diff --git a/lib/widgets.dart b/lib/widgets.dart index 8451e164a..64a81e1b0 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -17,6 +17,7 @@ library widgets; import "package:flutter/material.dart"; +export "src/widgets/atomic/autonomy_command.dart"; export "src/widgets/atomic/camera_editor.dart"; export "src/widgets/atomic/editors.dart"; export "src/widgets/atomic/video_feed.dart"; From 55940191ed334d23486e3c90cf2d72b3d98a2b33 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:57:50 -0500 Subject: [PATCH 09/17] Upgraded science page to use ReactiveWidget --- lib/src/pages/science.dart | 172 ++++++++------------ lib/src/widgets/atomic/science_command.dart | 44 +++++ lib/widgets.dart | 1 + 3 files changed, 114 insertions(+), 103 deletions(-) create mode 100644 lib/src/widgets/atomic/science_command.dart diff --git a/lib/src/pages/science.dart b/lib/src/pages/science.dart index f2d870bd8..b83aa28e8 100644 --- a/lib/src/pages/science.dart +++ b/lib/src/pages/science.dart @@ -19,7 +19,7 @@ class DesktopScrollBehavior extends MaterialScrollBehavior { } /// A row of scrollable or non-scrollable widgets. -class ScrollingRow extends StatelessWidget { +class ScrollingRow extends ReusableReactiveWidget { /// The widgets to display. final List children; @@ -27,12 +27,12 @@ class ScrollingRow extends StatelessWidget { final double height; /// Renders a row of widgets. - const ScrollingRow({required this.children, this.height = 300}); + ScrollingRow({required this.children, this.height = 300}) : super(models.settings); @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: models.settings, - builder: (model) => SizedBox(height: height, child: model.science.scrollableGraphs + Widget build(BuildContext context, SettingsModel model) => SizedBox( + height: height, + child: model.science.scrollableGraphs ? ScrollConfiguration( behavior: DesktopScrollBehavior(), child: ListView( @@ -43,8 +43,7 @@ class ScrollingRow extends StatelessWidget { : Row( children: [for (final child in children) Expanded(child: child)], ), - ), - ); + ); } /// A [ScrollingRow] of charts, using [builder] on each [ScienceAnalysis] in [analyses]. @@ -92,7 +91,7 @@ GetTitleWidgetFunction getTitles(List titles) => (value, meta) => SideTi ); /// The science analysis page. -class SciencePage extends StatelessWidget { +class SciencePage extends ReactiveWidget { /// Red, used as the color for the first sample. static final red = HSVColor.fromColor(Colors.red); /// Purple, used as the color for the last sample. @@ -157,102 +156,69 @@ class SciencePage extends StatelessWidget { barTouchData: BarTouchData(touchTooltipData: BarTouchTooltipData(fitInsideVertically: true, fitInsideHorizontally: true)), ); + @override + ScienceModel createModel() => ScienceModel(); + @override - Widget build(BuildContext context) => ProviderConsumer( - create: ScienceModel.new, - builder: (model) => Column(children: [ - Row(children: [ // The header at the top - const SizedBox(width: 8), - Text("Science Analysis", style: context.textTheme.headlineMedium), - const SizedBox(width: 12), - if (model.isLoading) const SizedBox(height: 20, width: 20, child: CircularProgressIndicator()), - const Spacer(), - DropdownButton( - value: model.sample, - onChanged: model.updateSample, - items: [ - for (int i = 0; i < model.numSamples; i++) DropdownMenuItem( - value: i, - child: Text("Sample ${i + 1}"), - ), - ], - ), - if (model.isListening) IconButton( - icon: const Icon(Icons.upload_file), - onPressed: model.loadFile, - tooltip: "Load file", - ) else IconButton( - icon: const Icon(Icons.clear), - onPressed: model.clear, - tooltip: "Clear", - ), - const ViewsSelector(currentView: Routes.science), - ],), - Expanded(child: ListView( // The main content of the page - padding: const EdgeInsets.symmetric(horizontal: 4), - children: [ - if (model.errorText != null) ...[ - Text("Error analyzing the logs", textAlign: TextAlign.center, style: context.textTheme.headlineLarge), - const SizedBox(height: 24), - Text("Here is the error:", textAlign: TextAlign.center, style: context.textTheme.titleLarge), - const SizedBox(height: 12), - Text(model.errorText!, textAlign: TextAlign.center, style: context.textTheme.titleMedium), - ] else if (!model.isLoading) ...[ - ChartsRow( - title: "Details", - analyses: model.analysesForSample, - builder: (analysis) => LineChart(getDetailsData(analysis, getColor(model.sample / model.numSamples))), - ), - ChartsRow( - title: "Summary", - analyses: model.analysesForSample, - builder: (analysis) => BarChart(getBarChartData(analysis, getColor(model.sample / model.numSamples))), - ), - ChartsRow( - title: "Results", - height: 425, - analyses: model.analysesForSample, - builder: ResultsBox.new, - ), - ], - ], - ),), - ProviderConsumer( // the controls bar on the bottom of the page - create: ScienceCommandBuilder.new, - builder: (command) => Container( - color: context.colorScheme.surface, - height: 48, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), - Text("Status", style: context.textTheme.titleLarge), - const SizedBox(width: 12), - Text("Sample: ${models.rover.metrics.science.data.sample}", style: context.textTheme.titleMedium), - const SizedBox(width: 12), - Text("State: ${models.rover.metrics.science.data.state.humanName}", style: context.textTheme.titleMedium), - const Spacer(), - Text("Command", style: context.textTheme.titleLarge), - SizedBox(width: 125, child: NumberEditor(width: 4, name: "Sample: ", model: command.sample)), - SizedBox(child: DropdownEditor( - name: "State: ", - value: command.state, - onChanged: command.updateState, - items: const [ScienceState.STOP_COLLECTING, ScienceState.COLLECT_DATA], - humanName: (state) => state.humanName, - ),), - const SizedBox(width: 12), - ElevatedButton( - onPressed: command.isValid ? command.send : null, - child: const Text("Send"), - ), - const SizedBox(width: 12), - ], - ), - ), - ), - ],), - ); + Widget build(BuildContext context, ScienceModel model) => Column(children: [ + Row(children: [ // The header at the top + const SizedBox(width: 8), + Text("Science Analysis", style: context.textTheme.headlineMedium), + const SizedBox(width: 12), + if (model.isLoading) const SizedBox(height: 20, width: 20, child: CircularProgressIndicator()), + const Spacer(), + DropdownButton( + value: model.sample, + onChanged: model.updateSample, + items: [ + for (int i = 0; i < model.numSamples; i++) DropdownMenuItem( + value: i, + child: Text("Sample ${i + 1}"), + ), + ], + ), + if (model.isListening) IconButton( + icon: const Icon(Icons.upload_file), + onPressed: model.loadFile, + tooltip: "Load file", + ) else IconButton( + icon: const Icon(Icons.clear), + onPressed: model.clear, + tooltip: "Clear", + ), + const ViewsSelector(currentView: Routes.science), + ],), + Expanded(child: ListView( // The main content of the page + padding: const EdgeInsets.symmetric(horizontal: 4), + children: [ + if (model.errorText != null) ...[ + Text("Error analyzing the logs", textAlign: TextAlign.center, style: context.textTheme.headlineLarge), + const SizedBox(height: 24), + Text("Here is the error:", textAlign: TextAlign.center, style: context.textTheme.titleLarge), + const SizedBox(height: 12), + Text(model.errorText!, textAlign: TextAlign.center, style: context.textTheme.titleMedium), + ] else if (!model.isLoading) ...[ + ChartsRow( + title: "Details", + analyses: model.analysesForSample, + builder: (analysis) => LineChart(getDetailsData(analysis, getColor(model.sample / model.numSamples))), + ), + ChartsRow( + title: "Summary", + analyses: model.analysesForSample, + builder: (analysis) => BarChart(getBarChartData(analysis, getColor(model.sample / model.numSamples))), + ), + ChartsRow( + title: "Results", + height: 425, + analyses: model.analysesForSample, + builder: ResultsBox.new, + ), + ], + ], + ),), + ScienceCommandEditor(), + ],); } /// A box to display the final results for each sensor. diff --git a/lib/src/widgets/atomic/science_command.dart b/lib/src/widgets/atomic/science_command.dart new file mode 100644 index 000000000..803e6cc2d --- /dev/null +++ b/lib/src/widgets/atomic/science_command.dart @@ -0,0 +1,44 @@ +import "package:flutter/material.dart"; + +import "package:rover_dashboard/data.dart"; +import "package:rover_dashboard/models.dart"; +import "package:rover_dashboard/widgets.dart"; + +/// A widget to create and send a [ScienceCommand]. +class ScienceCommandEditor extends ReactiveWidget { + @override + ScienceCommandBuilder createModel() => ScienceCommandBuilder(); + + @override + Widget build(BuildContext context, ScienceCommandBuilder model) => Container( + color: context.colorScheme.surface, + height: 48, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text("Status", style: context.textTheme.titleLarge), + const SizedBox(width: 12), + Text("Sample: ${models.rover.metrics.science.data.sample}", style: context.textTheme.titleMedium), + const SizedBox(width: 12), + Text("State: ${models.rover.metrics.science.data.state.humanName}", style: context.textTheme.titleMedium), + const Spacer(), + Text("Command", style: context.textTheme.titleLarge), + SizedBox(width: 125, child: NumberEditor(width: 4, name: "Sample: ", model: model.sample)), + SizedBox(child: DropdownEditor( + name: "State: ", + value: model.state, + onChanged: model.updateState, + items: const [ScienceState.STOP_COLLECTING, ScienceState.COLLECT_DATA], + humanName: (state) => state.humanName, + ),), + const SizedBox(width: 12), + ElevatedButton( + onPressed: model.isValid ? model.send : null, + child: const Text("Send"), + ), + const SizedBox(width: 12), + ], + ), + ); +} diff --git a/lib/widgets.dart b/lib/widgets.dart index 64a81e1b0..3b10a1b61 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -20,6 +20,7 @@ import "package:flutter/material.dart"; export "src/widgets/atomic/autonomy_command.dart"; export "src/widgets/atomic/camera_editor.dart"; export "src/widgets/atomic/editors.dart"; +export "src/widgets/atomic/science_command.dart"; export "src/widgets/atomic/video_feed.dart"; export "src/widgets/generic/gamepad.dart"; From 835a46ec2274501d2ea11fa5ef7e9c2e988a4a2d Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 15:58:44 -0500 Subject: [PATCH 10/17] Upgraded settings page to use ReactiveWidget --- lib/src/pages/settings.dart | 348 ++++++++++++++++++------------------ 1 file changed, 174 insertions(+), 174 deletions(-) diff --git a/lib/src/pages/settings.dart b/lib/src/pages/settings.dart index df265bff5..99ee3a7a1 100644 --- a/lib/src/pages/settings.dart +++ b/lib/src/pages/settings.dart @@ -41,184 +41,184 @@ class ValueEditor extends StatelessWidget { } /// The settings page. -class SettingsPage extends StatelessWidget { +class SettingsPage extends ReactiveWidget { + @override + SettingsBuilder createModel() => SettingsBuilder(); + @override - Widget build(BuildContext context) => Scaffold( + Widget build(BuildContext context, SettingsBuilder model) => Scaffold( appBar: AppBar(title: const Text("Settings")), - body: ProviderConsumer( - create: SettingsBuilder.new, - builder: (model) => Column(children: [ - Expanded(child: ListView( - padding: const EdgeInsets.all(12), - children: [ - ValueEditor( - name: "Network settings", - children: [ - SocketEditor(name: "Subsystems socket", model: model.network.dataSocket), - SocketEditor(name: "Video socket", model: model.network.videoSocket), - SocketEditor(name: "Autonomy socket", model: model.network.autonomySocket), - SocketEditor(name: "Tank IP address", model: model.network.tankSocket, editPort: false), - ListTile( - title: const Text("Restart the network sockets"), - subtitle: const Text("This only resets your computer's network, not the rover's"), - trailing: const Icon(Icons.refresh), - onTap: () async { - await models.sockets.reset(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Network reset"), duration: Duration(milliseconds: 500)), - ); - } - }, - ), - ], - ), - const Divider(), - ValueEditor( - name: "Arm settings", - children: [ - NumberEditor(name: "Swivel increment", model: model.arm.swivel), - NumberEditor(name: "Shoulder increment", model: model.arm.shoulder), - NumberEditor(name: "Elbow increment", model: model.arm.elbow), - NumberEditor(name: "Wrist rotate increment", model: model.arm.rotate), - NumberEditor(name: "Wrist lift increment", model: model.arm.lift), - NumberEditor(name: "Pinch increment", model: model.arm.pinch), - NumberEditor(name: "IK increment", model: model.arm.ik), - SwitchListTile( - title: const Text("Use IK?"), - subtitle: const Text("Move in millimeters in 3D space instead of radians"), - value: model.arm.useIK, - onChanged: model.arm.updateIK, - ), - ], - ), - const Divider(), - ValueEditor( - name: "Science settings", - children: [ - NumberEditor( - name: "Number of samples", - model: model.science.numSamples, - ), - SwitchListTile( - title: const Text("Scrollable graphs"), - subtitle: const Text("Graphs can either be forced to fit the page or allowed to scroll\nMight be inconvenient for desktop users"), - value: model.science.scrollableGraphs, - onChanged: model.science.updateScrollableGraphs, - ), - ], - ), - const Divider(), - ValueEditor( - name: "Dashboard Settings", - children: [ - NumberEditor( - name: "Frames per second", - subtitle: "This does not affect the rover's cameras. Useful for limiting the CPU of the dashboard", - model: model.dashboard.fps, - ), - NumberEditor( - name: "Block size", - subtitle: "The precision of the GPS grid", - model: model.dashboard.blockSize, + body: Column(children: [ + Expanded(child: ListView( + padding: const EdgeInsets.all(12), + children: [ + ValueEditor( + name: "Network settings", + children: [ + SocketEditor(name: "Subsystems socket", model: model.network.dataSocket), + SocketEditor(name: "Video socket", model: model.network.videoSocket), + SocketEditor(name: "Autonomy socket", model: model.network.autonomySocket), + SocketEditor(name: "Tank IP address", model: model.network.tankSocket, editPort: false), + ListTile( + title: const Text("Restart the network sockets"), + subtitle: const Text("This only resets your computer's network, not the rover's"), + trailing: const Icon(Icons.refresh), + onTap: () async { + await models.sockets.reset(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Network reset"), duration: Duration(milliseconds: 500)), + ); + } + }, + ), + ], + ), + const Divider(), + ValueEditor( + name: "Arm settings", + children: [ + NumberEditor(name: "Swivel increment", model: model.arm.swivel), + NumberEditor(name: "Shoulder increment", model: model.arm.shoulder), + NumberEditor(name: "Elbow increment", model: model.arm.elbow), + NumberEditor(name: "Wrist rotate increment", model: model.arm.rotate), + NumberEditor(name: "Wrist lift increment", model: model.arm.lift), + NumberEditor(name: "Pinch increment", model: model.arm.pinch), + NumberEditor(name: "IK increment", model: model.arm.ik), + SwitchListTile( + title: const Text("Use IK?"), + subtitle: const Text("Move in millimeters in 3D space instead of radians"), + value: model.arm.useIK, + onChanged: model.arm.updateIK, + ), + ], + ), + const Divider(), + ValueEditor( + name: "Science settings", + children: [ + NumberEditor( + name: "Number of samples", + model: model.science.numSamples, + ), + SwitchListTile( + title: const Text("Scrollable graphs"), + subtitle: const Text("Graphs can either be forced to fit the page or allowed to scroll\nMight be inconvenient for desktop users"), + value: model.science.scrollableGraphs, + onChanged: model.science.updateScrollableGraphs, + ), + ], + ), + const Divider(), + ValueEditor( + name: "Dashboard Settings", + children: [ + NumberEditor( + name: "Frames per second", + subtitle: "This does not affect the rover's cameras. Useful for limiting the CPU of the dashboard", + model: model.dashboard.fps, + ), + NumberEditor( + name: "Block size", + subtitle: "The precision of the GPS grid", + model: model.dashboard.blockSize, + ), + Row(children: [ + const SizedBox( + width: 200, + child: ListTile( + title: Text("Split mode"), + ), ), - Row(children: [ - const SizedBox( - width: 200, - child: ListTile( - title: Text("Split mode"), + const Spacer(), + DropdownMenu( + initialSelection: model.dashboard.splitMode, + onSelected: model.dashboard.updateSplitMode, + dropdownMenuEntries: [ + for (final value in SplitMode.values) DropdownMenuEntry( + value: value, + label: value.humanName, ), - ), - const Spacer(), - DropdownMenu( - initialSelection: model.dashboard.splitMode, - onSelected: model.dashboard.updateSplitMode, - dropdownMenuEntries: [ - for (final value in SplitMode.values) DropdownMenuEntry( - value: value, - label: value.humanName, - ), - ], - ), - ],), - ], - ), - const Divider(), - ValueEditor( - name: "Easter eggs", - children: [ - SwitchListTile( - title: const Text("Enable SEGA Intro"), - value: model.easterEggs.segaIntro, - onChanged: model.easterEggs.updateSegaIntro, - ), - SwitchListTile( - title: const Text("Enable Clippy"), - value: model.easterEggs.enableClippy, - onChanged: model.easterEggs.updateClippy, - ), - ], - ), - const Divider(), - Text("Misc", style: Theme.of(context).textTheme.titleLarge), - ListTile( - title: const Text("Adjust throttle"), - subtitle: const Text("Sets the max speed on the rover"), - trailing: const Icon(Icons.speed), - onTap: () => showDialog( - context: context, - builder: (_) => ThrottleEditor(), + ], + ), + ],), + ], + ), + const Divider(), + ValueEditor( + name: "Easter eggs", + children: [ + SwitchListTile( + title: const Text("Enable SEGA Intro"), + value: model.easterEggs.segaIntro, + onChanged: model.easterEggs.updateSegaIntro, ), - ), - ListTile( - title: const Text("Open session output"), - subtitle: const Text("Opens all files created by this session"), - trailing: const Icon(Icons.launch), - onTap: () => launchUrl(services.files.loggingDir.uri), - ), - ListTile( - title: const Text("Open the output folder"), - subtitle: const Text("Contains logs, screenshots, and settings"), - trailing: const Icon(Icons.launch), - onTap: () => launchUrl(services.files.outputDir.uri), - ), - ListTile( - title: const Text("Change the LED strip color"), - subtitle: const Text("Opens an RGB picker"), - trailing: const Icon(Icons.launch), - onTap: () => showDialog(context: context, builder: (_) => ColorEditor(ColorBuilder())), - ), - ListTile( - title: const Text("Set a timer"), - subtitle: const Text("Shows a timer for the current mission"), - trailing: const Icon(Icons.launch), - onTap: () => showDialog(context: context, builder: (_) => TimerEditor()), - ), - ], - ),), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text("Cancel"), - ), - const SizedBox(width: 4), - ElevatedButton.icon( - onPressed: !model.isValid ? null : () async { - await model.save(); - if (context.mounted) Navigator.of(context).pop(); - }, - label: const Text("Save"), - icon: model.isLoading - ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator()) - : const Icon(Icons.save), - ), - const SizedBox(width: 4), - ], - ), - const SizedBox(height: 12), - ],), - ), + SwitchListTile( + title: const Text("Enable Clippy"), + value: model.easterEggs.enableClippy, + onChanged: model.easterEggs.updateClippy, + ), + ], + ), + const Divider(), + Text("Misc", style: Theme.of(context).textTheme.titleLarge), + ListTile( + title: const Text("Adjust throttle"), + subtitle: const Text("Sets the max speed on the rover"), + trailing: const Icon(Icons.speed), + onTap: () => showDialog( + context: context, + builder: (_) => ThrottleEditor(), + ), + ), + ListTile( + title: const Text("Open session output"), + subtitle: const Text("Opens all files created by this session"), + trailing: const Icon(Icons.launch), + onTap: () => launchUrl(services.files.loggingDir.uri), + ), + ListTile( + title: const Text("Open the output folder"), + subtitle: const Text("Contains logs, screenshots, and settings"), + trailing: const Icon(Icons.launch), + onTap: () => launchUrl(services.files.outputDir.uri), + ), + ListTile( + title: const Text("Change the LED strip color"), + subtitle: const Text("Opens an RGB picker"), + trailing: const Icon(Icons.launch), + onTap: () => showDialog(context: context, builder: (_) => ColorEditor(ColorBuilder())), + ), + ListTile( + title: const Text("Set a timer"), + subtitle: const Text("Shows a timer for the current mission"), + trailing: const Icon(Icons.launch), + onTap: () => showDialog(context: context, builder: (_) => TimerEditor()), + ), + ], + ),), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + const SizedBox(width: 4), + ElevatedButton.icon( + onPressed: !model.isValid ? null : () async { + await model.save(); + if (context.mounted) Navigator.of(context).pop(); + }, + label: const Text("Save"), + icon: model.isLoading + ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator()) + : const Icon(Icons.save), + ), + const SizedBox(width: 4), + ], + ), + const SizedBox(height: 12), + ],), ); } From 264aac2e45540141024d828e95c079c0fedc5058 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 16:38:50 -0500 Subject: [PATCH 11/17] Upgraded all widgets to ReactiveWidget' --- lib/app.dart | 67 +++--- lib/src/pages/home.dart | 2 +- lib/src/pages/map.dart | 2 +- lib/src/widgets/atomic/autonomy_command.dart | 2 +- lib/src/widgets/atomic/camera_editor.dart | 116 +++++----- lib/src/widgets/atomic/editors.dart | 224 +++++++++---------- lib/src/widgets/generic/gamepad.dart | 76 +++---- lib/src/widgets/generic/reactive_widget.dart | 18 ++ lib/src/widgets/navigation/footer.dart | 97 ++++---- lib/src/widgets/navigation/sidebar.dart | 81 +++---- lib/src/widgets/navigation/views.dart | 166 ++++++-------- 11 files changed, 386 insertions(+), 465 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 54353aac2..08f7074b7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -7,9 +7,6 @@ library app; import "package:flutter/material.dart"; -import "package:provider/provider.dart"; - -import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/pages.dart"; /// The classic Binghamton green. @@ -18,43 +15,31 @@ const binghamtonGreen = Color(0xff005943); /// The main class for the app. class RoverControlDashboard extends StatelessWidget { @override - Widget build(BuildContext context) => MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: models), - ChangeNotifierProvider.value(value: models.video), - ChangeNotifierProvider.value(value: models.home), - ChangeNotifierProvider.value(value: models.rover), - ChangeNotifierProvider.value(value: models.serial), - ChangeNotifierProvider.value(value: models.settings), - ], - child: Consumer( - builder: (context, models, _) => MaterialApp( - title: "Binghamton University Rover Team", - home: SplashPage(), - debugShowCheckedModeBanner: false, - theme: ThemeData( - useMaterial3: false, - colorScheme: const ColorScheme.light( - primary: binghamtonGreen, - secondary: binghamtonGreen, - ), - appBarTheme: const AppBarTheme( - backgroundColor: binghamtonGreen, - // titleTextStyle: TextStyle(color: Colors.white), - foregroundColor: Colors.white, - ), - ), - darkTheme: ThemeData.from( - colorScheme: const ColorScheme.dark( - primary: binghamtonGreen, - secondary: binghamtonGreen, - ), - ), - routes: { - Routes.home: (_) => HomePage(), - Routes.settings: (_) => SettingsPage(), - }, - ), - ), + Widget build(BuildContext context) => MaterialApp( + title: "Binghamton University Rover Team", + home: SplashPage(), + debugShowCheckedModeBanner: false, + theme: ThemeData( + useMaterial3: false, + colorScheme: const ColorScheme.light( + primary: binghamtonGreen, + secondary: binghamtonGreen, + ), + appBarTheme: const AppBarTheme( + backgroundColor: binghamtonGreen, + // titleTextStyle: TextStyle(color: Colors.white), + foregroundColor: Colors.white, + ), + ), + darkTheme: ThemeData.from( + colorScheme: const ColorScheme.dark( + primary: binghamtonGreen, + secondary: binghamtonGreen, + ), + ), + routes: { + Routes.home: (_) => HomePage(), + Routes.settings: (_) => SettingsPage(), + }, ); } diff --git a/lib/src/pages/home.dart b/lib/src/pages/home.dart index 030057428..7aadf6bd6 100644 --- a/lib/src/pages/home.dart +++ b/lib/src/pages/home.dart @@ -58,7 +58,7 @@ class HomePageState extends State{ bottomNavigationBar: const Footer(), body: Row( children: [ - const Expanded(child: ViewsWidget()), + Expanded(child: ViewsWidget()), // An AnimatedSize widget automatically shrinks the widget away AnimatedSize( duration: const Duration(milliseconds: 250), diff --git a/lib/src/pages/map.dart b/lib/src/pages/map.dart index 595142cdb..5a5e3e0e9 100644 --- a/lib/src/pages/map.dart +++ b/lib/src/pages/map.dart @@ -28,7 +28,7 @@ class MapPage extends ReactiveWidget { content: Column( mainAxisSize: MainAxisSize.min, children: [ - GpsEditor(model: model.markerBuilder), + GpsEditor(model.markerBuilder), ], ), actions: [ diff --git a/lib/src/widgets/atomic/autonomy_command.dart b/lib/src/widgets/atomic/autonomy_command.dart index acffafe4e..bebecb2fa 100644 --- a/lib/src/widgets/atomic/autonomy_command.dart +++ b/lib/src/widgets/atomic/autonomy_command.dart @@ -27,7 +27,7 @@ class AutonomyCommandEditor extends ReactiveWidget { onChanged: command.updateTask, humanName: (task) => task.humanName, ), - GpsEditor(model: command.gps), + GpsEditor(command.gps), ], ), actions: [ diff --git a/lib/src/widgets/atomic/camera_editor.dart b/lib/src/widgets/atomic/camera_editor.dart index a40ae0990..1bcc13f16 100644 --- a/lib/src/widgets/atomic/camera_editor.dart +++ b/lib/src/widgets/atomic/camera_editor.dart @@ -5,7 +5,7 @@ import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/widgets.dart"; /// A widget to modify [CameraDetails] for a given camera, backed by a [CameraDetailsBuilder]. -class CameraDetailsEditor extends StatelessWidget { +class CameraDetailsEditor extends ReactiveWidget { /// The data for the camera being modified. /// /// This must be a [VideoData] and not a [CameraDetails] to get the camera's ID. @@ -14,63 +14,63 @@ class CameraDetailsEditor extends StatelessWidget { /// Creates a widget to modify a [CameraDetails]. const CameraDetailsEditor(this.data); + @override + CameraDetailsBuilder createModel() => CameraDetailsBuilder(data.details); + @override - Widget build(BuildContext context) => ProviderConsumer( - create: () => CameraDetailsBuilder(data.details), - builder: (model) => AlertDialog( - title: const Text("Modify camera"), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text("Cancel"), - ), - ElevatedButton( - onPressed: !model.isValid ? null : () async { - final result = await model.saveSettings(data.id); - if (result && context.mounted) Navigator.of(context).pop(); - }, - child: const Text("Save"), - ), - ], - content: SingleChildScrollView( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: DropdownEditor( - name: "Status", - humanName: (value) => value.humanName, - value: model.status, - onChanged: model.updateStatus, - items: CameraDetailsBuilder.okStatuses, - ), - ), - NumberEditor( - name: "Resolution height", - model: model.resolutionHeight, - titleFlex: 2, - ), - NumberEditor( - name: "Resolution width", - model: model.resolutionWidth, - titleFlex: 2, - ), - NumberEditor( - name: "Quality (0-100)", - model: model.quality, - titleFlex: 2, - ), - NumberEditor( - name: "Frames per second", - model: model.fps, - titleFlex: 2, - ), - const SizedBox(height: 24), - if (model.isLoading) const Text("Loading..."), - if (model.error != null) Text(model.error!, style: const TextStyle(color: Colors.red)), - ], - ), - ), - ), + Widget build(BuildContext context, CameraDetailsBuilder model) => AlertDialog( + title: const Text("Modify camera"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: !model.isValid ? null : () async { + final result = await model.saveSettings(data.id); + if (result && context.mounted) Navigator.of(context).pop(); + }, + child: const Text("Save"), + ), + ], + content: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DropdownEditor( + name: "Status", + humanName: (value) => value.humanName, + value: model.status, + onChanged: model.updateStatus, + items: CameraDetailsBuilder.okStatuses, + ), + ), + NumberEditor( + name: "Resolution height", + model: model.resolutionHeight, + titleFlex: 2, + ), + NumberEditor( + name: "Resolution width", + model: model.resolutionWidth, + titleFlex: 2, + ), + NumberEditor( + name: "Quality (0-100)", + model: model.quality, + titleFlex: 2, + ), + NumberEditor( + name: "Frames per second", + model: model.fps, + titleFlex: 2, + ), + const SizedBox(height: 24), + if (model.isLoading) const Text("Loading..."), + if (model.error != null) Text(model.error!, style: const TextStyle(color: Colors.red)), + ], + ), + ), ); } diff --git a/lib/src/widgets/atomic/editors.dart b/lib/src/widgets/atomic/editors.dart index 5016224fd..7bde92351 100644 --- a/lib/src/widgets/atomic/editors.dart +++ b/lib/src/widgets/atomic/editors.dart @@ -6,64 +6,53 @@ import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/widgets.dart"; /// Creates a widget to edit a [SocketInfo], backed by [SocketBuilder]. -class SocketEditor extends StatelessWidget { +class SocketEditor extends ReusableReactiveWidget { /// The name of the socket being edited. final String name; - /// The [SocketBuilder] view model behind this widget. - /// - /// Performs validation and tracks the text entered into the fields. - final SocketBuilder model; - /// Whether to edit the port as well. final bool editPort; /// Creates a widget to edit host and port data for a socket. const SocketEditor({ required this.name, - required this.model, + required SocketBuilder model, this.editPort = true, - }); + }) : super(model); @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: model, - builder: (model) => Row( - children: [ - const SizedBox(width: 16), - Expanded(flex: 5, child: Text(name)), - const Spacer(), - Expanded(child: TextField( - onChanged: model.address.update, - controller: model.address.controller, - decoration: InputDecoration(errorText: model.address.error), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"\d|\."))], - ),), - const SizedBox(width: 12), - if (editPort) ...[ - Expanded(child: TextField( - onChanged: model.port.update, - controller: model.port.controller, - decoration: InputDecoration(errorText: model.port.error), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"\d"))], - ),), - ] else const Spacer(), - ], - ), + Widget build(BuildContext context, SocketBuilder model) => Row( + children: [ + const SizedBox(width: 16), + Expanded(flex: 5, child: Text(name)), + const Spacer(), + Expanded(child: TextField( + onChanged: model.address.update, + controller: model.address.controller, + decoration: InputDecoration(errorText: model.address.error), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"\d|\."))], + ),), + const SizedBox(width: 12), + if (editPort) ...[ + Expanded(child: TextField( + onChanged: model.port.update, + controller: model.port.controller, + decoration: InputDecoration(errorText: model.port.error), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"\d"))], + ),), + ] else const Spacer(), + ], ); } /// A widget to edit a number, backed by [NumberBuilder]. -class NumberEditor extends StatelessWidget { +class NumberEditor extends ReusableReactiveWidget { /// The value this number represents. final String name; /// Shows extra details. final String? subtitle; - /// The view model backing this value. - final NumberBuilder model; - /// How much space to allocate in between the label and text field. final double? width; @@ -72,36 +61,33 @@ class NumberEditor extends StatelessWidget { /// Creates a widget to modify a number. const NumberEditor({ + required NumberBuilder model, required this.name, - required this.model, this.subtitle, this.titleFlex = 4, this.width, - }); + }) : super(model); @override - Widget build(BuildContext context) => ProviderConsumer>.value( - value: model, - builder: (model) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - flex: titleFlex, - child: subtitle == null ? ListTile(title: Text(name)) : ListTile( - title: Text(name), - subtitle: Text(subtitle!), - ), - ), - if (width == null) const Spacer() - else SizedBox(width: width), - Expanded(child: TextField( - onChanged: model.update, - decoration: InputDecoration(errorText: model.error), - controller: model.controller, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"\d|\.|-"))], - ),), - ], - ), + Widget build(BuildContext context, NumberBuilder model) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + flex: titleFlex, + child: subtitle == null ? ListTile(title: Text(name)) : ListTile( + title: Text(name), + subtitle: Text(subtitle!), + ), + ), + if (width == null) const Spacer() + else SizedBox(width: width), + Expanded(child: TextField( + onChanged: model.update, + decoration: InputDecoration(errorText: model.error), + controller: model.controller, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"\d|\.|-"))], + ),), + ], ); } @@ -155,41 +141,36 @@ class DropdownEditor extends StatelessWidget { } /// A widget to edit a color, backed by [ColorBuilder]. -class ColorEditor extends StatelessWidget { - /// The view model for this color. - final ColorBuilder model; +class ColorEditor extends ReusableReactiveWidget { /// A widget that modifies the given view model's color. - const ColorEditor(this.model); + const ColorEditor(super.model); @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: model, - builder: (model) => AlertDialog( - title: const Text("Pick a color"), - actions: [ - TextButton(child: const Text("Cancel"), onPressed: () => Navigator.of(context).pop()), - ElevatedButton( - onPressed: () async { - final result = await model.setColor(); - if (result && context.mounted) Navigator.of(context).pop(); - }, - child: const Text("Save"), - ), - ], - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Slider( - value: model.slider, - onChanged: model.updateSlider, - label: "color", - ), - Container(height: 50, width: double.infinity, color: model.value), - if (model.isLoading) const Text("Loading..."), - if (model.errorText != null) Text(model.errorText!, style: const TextStyle(color: Colors.red)), - ], - ), - ), + Widget build(BuildContext context, ColorBuilder model) => AlertDialog( + title: const Text("Pick a color"), + actions: [ + TextButton(child: const Text("Cancel"), onPressed: () => Navigator.of(context).pop()), + ElevatedButton( + onPressed: () async { + final result = await model.setColor(); + if (result && context.mounted) Navigator.of(context).pop(); + }, + child: const Text("Save"), + ), + ], + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Slider( + value: model.slider, + onChanged: model.updateSlider, + label: "color", + ), + Container(height: 50, width: double.infinity, color: model.value), + if (model.isLoading) const Text("Loading..."), + if (model.errorText != null) Text(model.errorText!, style: const TextStyle(color: Colors.red)), + ], + ), ); } @@ -235,42 +216,37 @@ class TimerEditor extends ReactiveWidget { } /// 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; +class GpsEditor extends ReusableReactiveWidget { /// Listens to [model] to rebuild the UI. - const GpsEditor({required this.model}); + const GpsEditor(super.model); @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: model, - builder: (model) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DropdownEditor( - name: "Type", - value: model.type, - onChanged: model.updateType, - items: GpsType.values, - humanName: (type) => type.humanName, - ), - const SizedBox(width: 12), - if (model.type == GpsType.degrees) ...[ - 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)), - 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)), - ] else ...[ - 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)), - ], + Widget build(BuildContext context, GpsBuilder model) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownEditor( + name: "Type", + value: model.type, + onChanged: model.updateType, + items: GpsType.values, + humanName: (type) => type.humanName, + ), + const SizedBox(width: 12), + if (model.type == GpsType.degrees) ...[ + 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)), + 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)), + ] else ...[ + 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)), ], - ), - ); + ], + ); } /// An [AlertDialog] to prompt the user for a throttle value and send it to the rover. diff --git a/lib/src/widgets/generic/gamepad.dart b/lib/src/widgets/generic/gamepad.dart index 932af84a4..090b0c6e8 100644 --- a/lib/src/widgets/generic/gamepad.dart +++ b/lib/src/widgets/generic/gamepad.dart @@ -10,14 +10,9 @@ import "package:rover_dashboard/widgets.dart"; /// - Clicking on the icon connects to the gamepad /// - The icon shows the battery level/connection of the gamepad /// - The dropdown menu allows the user to switch [OperatingMode]s -class GamepadButton extends StatelessWidget { - /// The controller being displayed. - final Controller controller; - +class GamepadButton extends ReusableReactiveWidget { /// A const constructor for this widget. - const GamepadButton({ - required this.controller, - }); + const GamepadButton(super.model); /// Returns a color representing the gamepad's battery level. Color getColor(GamepadBatteryLevel battery) { @@ -31,40 +26,37 @@ class GamepadButton extends StatelessWidget { } @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: controller, - builder: (model) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Stack( - children: [ - const Icon(Icons.sports_esports), - Positioned( - bottom: -2, - right: -2, - child: Text("${controller.gamepadIndex + 1}", style: const TextStyle(fontSize: 12, color: Colors.white)), - ), - ], - ), - color: model.isConnected - ? getColor(model.gamepad.battery) - : Colors.black, - constraints: const BoxConstraints(maxWidth: 36), - onPressed: controller.connect, - ), - DropdownButton( - iconEnabledColor: Colors.black, - value: controller.mode, - onChanged: controller.setMode, - items: [ - for (final mode in OperatingMode.values) DropdownMenuItem( - value: mode, - child: Text(mode.name), - ), - ], - ), - ], - ), + Widget build(BuildContext context, Controller model) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Stack( + children: [ + const Icon(Icons.sports_esports), + Positioned( + bottom: -2, + right: -2, + child: Text("${model.gamepadIndex + 1}", style: const TextStyle(fontSize: 12, color: Colors.white)), + ), + ], + ), + color: model.isConnected + ? getColor(model.gamepad.battery) + : Colors.black, + constraints: const BoxConstraints(maxWidth: 36), + onPressed: model.connect, + ), + DropdownButton( + iconEnabledColor: Colors.black, + value: model.mode, + onChanged: model.setMode, + items: [ + for (final mode in OperatingMode.values) DropdownMenuItem( + value: mode, + child: Text(mode.name), + ), + ], + ), + ], ); } diff --git a/lib/src/widgets/generic/reactive_widget.dart b/lib/src/widgets/generic/reactive_widget.dart index 051cbac26..3b28466ee 100644 --- a/lib/src/widgets/generic/reactive_widget.dart +++ b/lib/src/widgets/generic/reactive_widget.dart @@ -1,8 +1,20 @@ import "package:flutter/material.dart"; +/// A widget that listens to a [ChangeNotifier] (called the view model) and updates when it does. +/// +/// - If you're listening to an existing view model, use [ReusableReactiveWidget]. +/// - If you're listening to a view model created by this widget, use [ReactiveWidget]. abstract class ReactiveWidgetInterface extends StatefulWidget { + /// A const constructor. const ReactiveWidgetInterface({super.key}); + /// Creates the view model. This is only called once in the widget's lifetime. T createModel(); + /// Whether this widget should dispose the model after it's destroyed. + /// + /// Normally, we want the widget to clean up after itself and dispose its view model. But it's + /// also common for one view model to create and depend on another model. In this case, if we + /// are listening to the sub-model, we don't want to dispose it while the parent model is still + /// using it. bool get shouldDispose; @override @@ -11,6 +23,9 @@ abstract class ReactiveWidgetInterface extends Statefu /// Builds the UI according to the state in [model]. Widget build(BuildContext context, T model); + /// This function gives you an opportunity to update the view model when the widget updates. + /// + /// For more details, see [State.didUpdateWidget]. @mustCallSuper void didUpdateWidget(covariant ReactiveWidgetInterface oldWidget, T model) { } } @@ -28,8 +43,11 @@ abstract class ReactiveWidget extends ReactiveWidgetIn bool get shouldDispose => true; } +/// A [ReactiveWidgetInterface] that "borrows" a view model and does not dispose of it. abstract class ReusableReactiveWidget extends ReactiveWidgetInterface { + /// The model to borrow. final T model; + /// A const constructor. const ReusableReactiveWidget(this.model); @override diff --git a/lib/src/widgets/navigation/footer.dart b/lib/src/widgets/navigation/footer.dart index 843f4ef73..541f42576 100644 --- a/lib/src/widgets/navigation/footer.dart +++ b/lib/src/widgets/navigation/footer.dart @@ -1,5 +1,4 @@ import "package:flutter/material.dart"; -import "package:provider/provider.dart"; import "package:rover_dashboard/data.dart"; import "package:rover_dashboard/models.dart"; @@ -24,15 +23,15 @@ class Footer extends StatelessWidget { Wrap( // Groups these elements together even when wrapping // mainAxisSize: MainAxisSize.min, children: [ - const ViewsCounter(), + ViewsCounter(), const SizedBox(width: 8), // GamepadButtons(), - GamepadButton(controller: models.rover.controller1), + GamepadButton(models.rover.controller1), const SizedBox(width: 8), - GamepadButton(controller: models.rover.controller2), + GamepadButton(models.rover.controller2), const SizedBox(width: 8), - GamepadButton(controller: models.rover.controller3), - const SerialButton(), + GamepadButton(models.rover.controller3), + SerialButton(), const SizedBox(width: 4), const StatusIcons(), ], @@ -174,73 +173,65 @@ class StatusIcons extends StatelessWidget { } /// A dropdown to select more or less views. -class ViewsCounter extends StatelessWidget { +class ViewsCounter extends ReusableReactiveWidget { /// Provides a const constructor for this widget. - const ViewsCounter(); + ViewsCounter() : super(models.views); @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: models.views, - builder: (model) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text("Views:"), - const SizedBox(width: 4), - DropdownButton( - iconEnabledColor: Colors.black, - value: model.views.length, - onChanged: model.setNumViews, - items: [ - for (int i = 1; i <= 4; i++) DropdownMenuItem( - value: i, - child: Center(child: Text(i.toString())), - ), - ], - ), - ], - ), + Widget build(BuildContext context, ViewsModel model) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("Views:"), + const SizedBox(width: 4), + DropdownButton( + iconEnabledColor: Colors.black, + value: model.views.length, + onChanged: model.setNumViews, + items: [ + for (int i = 1; i <= 4; i++) DropdownMenuItem( + value: i, + child: Center(child: Text(i.toString())), + ), + ], + ), + ], ); } /// Allows the user to connect to the firmware directly, over Serial. /// /// See [SerialModel] for an implementation. -class SerialButton extends StatelessWidget { +class SerialButton extends ReusableReactiveWidget { /// Provides a const constructor. - const SerialButton(); + SerialButton() : super(models.serial); @override - Widget build(BuildContext context) => Consumer( - builder: (_, model, __) => PopupMenuButton( - icon: Icon( - Icons.usb, - color: model.hasDevice ? Colors.green : context.colorScheme.onSecondary, - ), - tooltip: "Select device", - onSelected: model.toggle, - itemBuilder: (_) => [ - for (final String port in SerialDevice.availablePorts) PopupMenuItem( - value: port, - child: ListTile( - title: Text(port), - leading: model.isConnected(port) ? const Icon(Icons.check) : null, - ), - ), - ], - ), + Widget build(BuildContext context, SerialModel model) => PopupMenuButton( + icon: Icon( + Icons.usb, + color: model.hasDevice ? Colors.green : context.colorScheme.onSecondary, + ), + tooltip: "Select device", + onSelected: model.toggle, + itemBuilder: (_) => [ + for (final String port in SerialDevice.availablePorts) PopupMenuItem( + value: port, + child: ListTile( + title: Text(port), + leading: model.isConnected(port) ? const Icon(Icons.check) : null, + ), + ), + ], ); } /// Displays the latest [TaskbarMessage] from [HomeModel.message]. -class MessageDisplay extends ReactiveWidget { +class MessageDisplay extends ReusableReactiveWidget { /// Whether to show an option to open the logs page. final bool showLogs; /// Provides a const constructor for this widget. - const MessageDisplay({required this.showLogs}) : super(shouldDispose: false); - - @override - HomeModel createModel() => models.home; + MessageDisplay({required this.showLogs}) : super(models.home); /// Gets the appropriate icon for the given severity. IconData getIcon(Severity? severity) { diff --git a/lib/src/widgets/navigation/sidebar.dart b/lib/src/widgets/navigation/sidebar.dart index d237c9d78..899d8547f 100644 --- a/lib/src/widgets/navigation/sidebar.dart +++ b/lib/src/widgets/navigation/sidebar.dart @@ -17,8 +17,9 @@ class Sidebar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 4), children: [ Text("Metrics", style: context.textTheme.displaySmall, textAlign: TextAlign.center), - const MetricsList(), - const Divider(), + for (final metrics in models.rover.metrics.allMetrics) + MetricsList(metrics), + const Divider(), Text("Controls", style: context.textTheme.displaySmall, textAlign: TextAlign.center), const SizedBox(height: 4), ControlsDisplay(controller: models.rover.controller1, gamepadNum: 1), @@ -30,61 +31,49 @@ class Sidebar extends StatelessWidget { } /// Displays metrics of all sorts in a collapsible list. -class MetricsList extends StatelessWidget { +class MetricsList extends ReusableReactiveWidget { /// A const constructor for this widget. - const MetricsList(); + const MetricsList(super.model); @override - Widget build(BuildContext context) => Column( - children: [ - for (final metrics in models.rover.metrics.allMetrics) ProviderConsumer>.value( - value: metrics, - builder: (metrics) => ExpansionTile( - expandedCrossAxisAlignment: CrossAxisAlignment.start, - expandedAlignment: Alignment.centerLeft, - childrenPadding: const EdgeInsets.symmetric(horizontal: 16), - title: Text( - metrics.name, - style: Theme.of(context).textTheme.headlineSmall, - ), - children: [ - for (final String metric in metrics.allMetrics) Text(metric), - const SizedBox(height: 4), - ], - ),), - ], - ); + Widget build(BuildContext context, Metrics model) => ExpansionTile( + expandedCrossAxisAlignment: CrossAxisAlignment.start, + expandedAlignment: Alignment.centerLeft, + childrenPadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text( + model.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + children: [ + for (final String metric in model.allMetrics) Text(metric), + const SizedBox(height: 4), + ], + ); } /// Displays controls for the given [Controller]. -class ControlsDisplay extends StatelessWidget { - /// The controller to display controls for. - final Controller controller; - +class ControlsDisplay extends ReusableReactiveWidget { /// The number gamepad being used. final int gamepadNum; /// A const constructor for this widget. - const ControlsDisplay({required this.controller, required this.gamepadNum}); + const ControlsDisplay({required Controller controller, required this.gamepadNum}) : super(controller); @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: controller, - builder: (_) => ExpansionTile( - expandedCrossAxisAlignment: CrossAxisAlignment.start, - expandedAlignment: Alignment.centerLeft, - childrenPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - title: Text( - controller.controls.mode.name, - style: Theme.of(context).textTheme.titleLarge, - textAlign: TextAlign.start, - ), - children: [ - for (final entry in controller.controls.buttonMapping.entries) ...[ - Text(entry.key, style: Theme.of(context).textTheme.labelLarge), - Text(" ${entry.value}", style: Theme.of(context).textTheme.titleMedium), - ], - ], - ), + Widget build(BuildContext context, Controller model) => ExpansionTile( + expandedCrossAxisAlignment: CrossAxisAlignment.start, + expandedAlignment: Alignment.centerLeft, + childrenPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + title: Text( + model.controls.mode.name, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.start, + ), + children: [ + for (final entry in model.controls.buttonMapping.entries) ...[ + Text(entry.key, style: Theme.of(context).textTheme.labelLarge), + Text(" ${entry.value}", style: Theme.of(context).textTheme.titleMedium), + ], + ], ); } diff --git a/lib/src/widgets/navigation/views.dart b/lib/src/widgets/navigation/views.dart index dbbd3b8a9..ef49cbe18 100644 --- a/lib/src/widgets/navigation/views.dart +++ b/lib/src/widgets/navigation/views.dart @@ -6,107 +6,77 @@ import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/widgets.dart"; /// A widget to render all the views the user selected. -class ViewsWidget extends StatelessWidget { +class ViewsWidget extends ReusableReactiveWidget { /// A const constructor. - const ViewsWidget(); + ViewsWidget() : super(models.views); @override - Widget build(BuildContext context) => ProviderConsumer.value( - value: models.views, - builder: (model) => LayoutBuilder( - builder: (context, constraints) => switch (model.views.length) { - 1 => Column(children: [Expanded(child: models.views.views.first.builder(context))]), - 2 => ResizableContainer( - direction: switch (models.settings.dashboard.splitMode) { - SplitMode.horizontal => Axis.vertical, - SplitMode.vertical => Axis.horizontal, - }, - dividerWidth: 5, - dividerColor: Colors.black, - children: [ - ResizableChildData( - startingRatio: 0.5, - child: models.views.views[0].builder(context), + Widget build(BuildContext context, ViewsModel model) => switch (model.views.length) { + 1 => Column(children: [Expanded(child: models.views.views.first.builder(context))]), + 2 => ResizableContainer( + direction: switch (models.settings.dashboard.splitMode) { + SplitMode.horizontal => Axis.vertical, + SplitMode.vertical => Axis.horizontal, + }, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[0].builder(context), + ), + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[1].builder(context), + ), + ], + ), + 3 || 4 => ResizableContainer( + direction: Axis.vertical, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: ResizableContainer( + direction: Axis.horizontal, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[0].builder(context), + ), + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[1].builder(context), + ), + ], ), - ResizableChildData( - startingRatio: 0.5, - child: models.views.views[1].builder(context), + ), + if (model.views.length == 3) ResizableChildData( + startingRatio: 0.5, + child: models.views.views[2].builder(context), + ) else ResizableChildData( + startingRatio: 0.5, + child: ResizableContainer( + direction: Axis.horizontal, + dividerWidth: 5, + dividerColor: Colors.black, + children: [ + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[2].builder(context), + ), + ResizableChildData( + startingRatio: 0.5, + child: models.views.views[3].builder(context), + ), + ], ), - ], - ), - 3 => ResizableContainer( - direction: Axis.vertical, - dividerWidth: 5, - dividerColor: Colors.black, - children: [ - ResizableChildData( - startingRatio: 0.5, - child: ResizableContainer( - direction: Axis.horizontal, - dividerWidth: 5, - dividerColor: Colors.black, - children: [ - ResizableChildData( - startingRatio: 0.5, - child: models.views.views[0].builder(context), - ), - ResizableChildData( - startingRatio: 0.5, - child: models.views.views[1].builder(context), - ), - ], - ), - ), - ResizableChildData( - startingRatio: 0.5, - child: models.views.views[2].builder(context), - ), - ], - ), - 4 => ResizableContainer( - direction: Axis.vertical, - dividerWidth: 5, - dividerColor: Colors.black, - children: [ - ResizableChildData( - startingRatio: 0.5, - child: ResizableContainer( - direction: Axis.horizontal, - dividerWidth: 5, - dividerColor: Colors.black, - children: [ - ResizableChildData( - startingRatio: 0.5, - child: models.views.views[0].builder(context), - ), - ResizableChildData( - startingRatio: 0.5, - child: models.views.views[1].builder(context), - ), - ], - ), - ), - ResizableChildData( - startingRatio: 0.5, - child: ResizableContainer( - direction: Axis.horizontal, - dividerWidth: 5, - dividerColor: Colors.black, - children: [ - ResizableChildData( - startingRatio: 0.5, - child: models.views.views[2].builder(context), - ), - ResizableChildData( - startingRatio: 0.5, - child: models.views.views[3].builder(context), - ), - ], - ), - ), - ], - ), - _ => throw StateError("Too many views: ${model.views.length}"), - },), - ); + ), + ], + ), + _ => throw StateError("Too many views: ${model.views.length}"), + }; } From bf6c0aaaffe94374b761ab3c137748280ca218b2 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 15 Feb 2024 16:39:22 -0500 Subject: [PATCH 12/17] Bumped version --- pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index ef5bf6927..262994ad4 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: 2024.2.9+1 +version: 2024.2.15+1 environment: sdk: "^3.0.0" @@ -50,7 +50,7 @@ flutter_launcher_icons: # Builds a Windows .msix App Installer file for the Dashboard. # Command: dart run msix:create msix_config: - msix_version: 2024.2.9.1 + msix_version: 2024.2.15.1 display_name: Dashboard publisher_display_name: Binghamton University Rover Team identity_name: edu.binghamton.rover From 739b796b0df09e41322f5aa95ff4474b53918695 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Fri, 16 Feb 2024 03:01:11 -0500 Subject: [PATCH 13/17] Implemented Manual, autonomous, and idle modes --- lib/src/models/data/home.dart | 2 +- lib/src/models/data/sockets.dart | 7 ++- lib/src/models/rover/settings.dart | 22 +++++++--- lib/src/widgets/generic/gamepad.dart | 66 +++++++++++++++------------- 4 files changed, 59 insertions(+), 38 deletions(-) diff --git a/lib/src/models/data/home.dart b/lib/src/models/data/home.dart index 616edb3bd..81d0ed36c 100644 --- a/lib/src/models/data/home.dart +++ b/lib/src/models/data/home.dart @@ -36,7 +36,7 @@ class HomeModel extends Model { _messageTimer?.cancel(); // the new message might be cleared if the old one were about to message = TaskbarMessage(severity: severity, text: text); notifyListeners(); - if (permanent) _hasError = true; + _hasError = permanent; _messageTimer = Timer(const Duration(seconds: 3), clear); } diff --git a/lib/src/models/data/sockets.dart b/lib/src/models/data/sockets.dart index f100594d1..63aef8775 100644 --- a/lib/src/models/data/sockets.dart +++ b/lib/src/models/data/sockets.dart @@ -75,7 +75,12 @@ class Sockets extends Model { /// Notifies the user when a new device has connected. void onConnect(Device device) { models.home.setMessage(severity: Severity.info, text: "The ${device.humanName} has connected"); - if (device == Device.SUBSYSTEMS) models.rover.status.value = models.rover.settings.status; + if (device == Device.SUBSYSTEMS) { + models.rover.status.value = models.rover.settings.status; + models.rover.controller1.gamepad.pulse(); + models.rover.controller2.gamepad.pulse(); + models.rover.controller3.gamepad.pulse(); + } } /// Notifies the user when a device has disconnected. diff --git a/lib/src/models/rover/settings.dart b/lib/src/models/rover/settings.dart index 2d5a503c2..eb286a605 100644 --- a/lib/src/models/rover/settings.dart +++ b/lib/src/models/rover/settings.dart @@ -48,12 +48,22 @@ class RoverSettings extends Model { /// /// See [RoverStatus] for details. Future setStatus(RoverStatus value) async { - final message = UpdateSetting(status: value); - models.sockets.video.sendMessage(message); - models.sockets.autonomy.sendMessage(message); - - if (!await tryChangeSettings(message)) return; - models.home.setMessage(severity: Severity.info, text: "Set mode to ${value.humanName}"); + if (value == RoverStatus.AUTONOMOUS || value == RoverStatus.IDLE) { + models.rover.controller1.setMode(OperatingMode.none); + models.rover.controller2.setMode(OperatingMode.none); + models.rover.controller3.setMode(OperatingMode.none); + } else if (value == RoverStatus.MANUAL) { + models.rover.controller1.setMode(OperatingMode.drive); + models.rover.controller3.setMode(OperatingMode.arm); + models.rover.controller2.setMode(OperatingMode.cameras); + } else { + final message = UpdateSetting(status: value); + models.sockets.video.sendMessage(message); + models.sockets.autonomy.sendMessage(message); + if (!await tryChangeSettings(message)) return; + } + + models.home.setMessage(severity: Severity.info, text: "Set mode to ${value.humanName}"); settings.status = value; models.rover.status.value = value; notifyListeners(); diff --git a/lib/src/widgets/generic/gamepad.dart b/lib/src/widgets/generic/gamepad.dart index 090b0c6e8..94b831b41 100644 --- a/lib/src/widgets/generic/gamepad.dart +++ b/lib/src/widgets/generic/gamepad.dart @@ -25,38 +25,44 @@ class GamepadButton extends ReusableReactiveWidget { } } + /// Whether the gamepad should be disabled in this mode. + bool isDisabled(RoverStatus status) => status == RoverStatus.AUTONOMOUS || status == RoverStatus.IDLE; + @override - Widget build(BuildContext context, Controller model) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Stack( - children: [ - const Icon(Icons.sports_esports), - Positioned( - bottom: -2, - right: -2, - child: Text("${model.gamepadIndex + 1}", style: const TextStyle(fontSize: 12, color: Colors.white)), + Widget build(BuildContext context, Controller model) => ValueListenableBuilder( + valueListenable: models.rover.status, + builder: (context, status, _) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Stack( + children: [ + const Icon(Icons.sports_esports), + Positioned( + bottom: -2, + right: -2, + child: Text("${model.gamepadIndex + 1}", style: const TextStyle(fontSize: 12, color: Colors.white)), + ), + ], + ), + color: isDisabled(status) ? Colors.grey : model.isConnected + ? getColor(model.gamepad.battery) + : Colors.black, + constraints: const BoxConstraints(maxWidth: 36), + onPressed: model.connect, + ), + DropdownButton( + iconEnabledColor: Colors.black, + value: model.mode, + onChanged: isDisabled(status) ? null : model.setMode, + items: [ + for (final mode in OperatingMode.values) DropdownMenuItem( + value: mode, + child: Text(mode.name), ), ], ), - color: model.isConnected - ? getColor(model.gamepad.battery) - : Colors.black, - constraints: const BoxConstraints(maxWidth: 36), - onPressed: model.connect, - ), - DropdownButton( - iconEnabledColor: Colors.black, - value: model.mode, - onChanged: model.setMode, - items: [ - for (final mode in OperatingMode.values) DropdownMenuItem( - value: mode, - child: Text(mode.name), - ), - ], - ), - ], - ); + ], + ), + ); } From feeeeb75c4b00c5d9958fc0734ad318ecd3da560 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Fri, 16 Feb 2024 03:11:17 -0500 Subject: [PATCH 14/17] Hopefully improved drive controls --- lib/src/models/rover/controller.dart | 1 + lib/src/models/rover/controls/controls.dart | 2 +- lib/src/models/rover/controls/drive.dart | 31 ++------------------- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/lib/src/models/rover/controller.dart b/lib/src/models/rover/controller.dart index 98ccfb9a0..10d81f07a 100644 --- a/lib/src/models/rover/controller.dart +++ b/lib/src/models/rover/controller.dart @@ -77,6 +77,7 @@ class Controller extends Model { if (!gamepad.isConnected) return; controls.updateState(gamepad.state); final messages = controls.parseInputs(gamepad.state); + messages.forEach(print); for (final message in messages) { if (message != null) models.messages.sendMessage(message); } diff --git a/lib/src/models/rover/controls/controls.dart b/lib/src/models/rover/controls/controls.dart index 7cdcf2a2a..bf933c966 100644 --- a/lib/src/models/rover/controls/controls.dart +++ b/lib/src/models/rover/controls/controls.dart @@ -16,7 +16,7 @@ export "none.dart"; export "science.dart"; /// How often to check the gamepad for new button presses. -const gamepadDelay = Duration(milliseconds: 100); +const gamepadDelay = Duration(milliseconds: 10); /// A class that controls one subsystem based on the gamepad state. /// diff --git a/lib/src/models/rover/controls/drive.dart b/lib/src/models/rover/controls/drive.dart index ab9e15d44..71e3ed440 100644 --- a/lib/src/models/rover/controls/drive.dart +++ b/lib/src/models/rover/controls/drive.dart @@ -5,41 +5,15 @@ import "controls.dart"; /// A [RoverControls] that drives the rover. class DriveControls extends RoverControls { - /// Increases the throttle by +/- 20%. - static const throttleIncrement = 0.1; - - /// The current throttle, as a percentage of the rover's top speed. - double throttle = 0; - @override OperatingMode get mode => OperatingMode.drive; @override List parseInputs(GamepadState state) => [ - // // Adjust throttle - // if (state.dpadUp) updateThrottle(throttleIncrement) - // else if (state.dpadDown) updateThrottle(-throttleIncrement), - // Adjust wheels DriveCommand(setLeft: true, left: state.normalLeftY), DriveCommand(setRight: true, right: -1*state.normalRightY), - // More intuitive controls - // if (state.normalShoulder != 0) ...[ - // DriveCommand(setLeft: true, left: state.normalShoulder), - // DriveCommand(setRight: true, right: state.normalShoulder), - // ], - // if (state.normalTrigger != 0) ...[ - // DriveCommand(setLeft: true, left: state.normalTrigger), - // DriveCommand(setRight: true, right: -1 * state.normalTrigger), - // ], ]; - /// Updates the throttle by [throttleIncrement], clamping at [0, 1]. - Message updateThrottle(double value) { - throttle += value; - throttle = throttle.clamp(0, 1); - return DriveCommand(setThrottle: true, throttle: throttle); - } - @override List get onDispose => [ DriveCommand(setThrottle: true, throttle: 0), @@ -51,8 +25,7 @@ class DriveControls extends RoverControls { Map get buttonMapping => { "Left Throttle": "Left joystick (vertical)", "Right Throttle": "Right joystick (vertical)", - "Drive Straight": "Triggers", - "Turn in place": "Bumpers", - "Adjust speed": "D-pad up/down", + // "Drive Straight": "Triggers", + // "Turn in place": "Bumpers", }; } From 944c766a9d6d44a462e4d5cdced49f7ece52bb31 Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Fri, 16 Feb 2024 03:19:42 -0500 Subject: [PATCH 15/17] Cleanup --- lib/data.dart | 3 +-- lib/src/data/constants.dart | 22 ---------------------- lib/src/models/rover/controller.dart | 1 - 3 files changed, 1 insertion(+), 25 deletions(-) delete mode 100644 lib/src/data/constants.dart diff --git a/lib/data.dart b/lib/data.dart index 61d31bd0f..ac5f04027 100644 --- a/lib/data.dart +++ b/lib/data.dart @@ -2,7 +2,7 @@ /// The data library. /// -/// This library defines any data types needed by the rest of the app. While the dataclasses may +/// This library defines any data types needed by the rest of the app. While the data classes may /// have methods, the logic within should be simple, and any broad logic that changes state should /// happen in the models library. /// @@ -21,7 +21,6 @@ export "src/data/metrics/mars.dart"; export "src/data/metrics/metrics.dart"; export "src/data/metrics/science.dart"; -export "src/data/constants.dart"; export "src/data/modes.dart"; export "src/data/protobuf.dart"; export "src/data/science.dart"; diff --git a/lib/src/data/constants.dart b/lib/src/data/constants.dart deleted file mode 100644 index 474980a28..000000000 --- a/lib/src/data/constants.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "dart:io"; - -// See Google Doc for up-to-date assignments: -// https://docs.google.com/document/d/1U6GxcYGpqUpSgtXFbiOTlMihNqcg6RbCqQmewx7cXJE - -/// The port used by the dashboard to send data from. -const dashboardSendPort = 8007; - -/// The IP address of the Subsystems Pi. -final subsystemsPiAddress = InternetAddress("192.168.1.20"); - -/// The port used by the subsystems program. -const subsystemsPort = 8002; - -/// The IP address of the Secondary Pi, for video and autonomy. -final secondaryPiAddress = InternetAddress("192.168.1.30"); - -/// The port used by the video program. -const videoPort = 8004; - -/// The port used by the autonomy program. -const autonomyPort = 8006; diff --git a/lib/src/models/rover/controller.dart b/lib/src/models/rover/controller.dart index 10d81f07a..98ccfb9a0 100644 --- a/lib/src/models/rover/controller.dart +++ b/lib/src/models/rover/controller.dart @@ -77,7 +77,6 @@ class Controller extends Model { if (!gamepad.isConnected) return; controls.updateState(gamepad.state); final messages = controls.parseInputs(gamepad.state); - messages.forEach(print); for (final message in messages) { if (message != null) models.messages.sendMessage(message); } From 8a029296259b39703039e374f492d6894b2a9c4f Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Fri, 16 Feb 2024 04:46:34 -0500 Subject: [PATCH 16/17] Ability to reset view ratios --- lib/src/models/data/views.dart | 23 +++++++++++++++++++++++ lib/src/models/rover/settings.dart | 1 + lib/src/pages/home.dart | 5 +++++ lib/src/widgets/navigation/views.dart | 10 ++++++++++ pubspec.lock | 9 +++++---- pubspec.yaml | 7 +++++++ 6 files changed, 51 insertions(+), 4 deletions(-) diff --git a/lib/src/models/data/views.dart b/lib/src/models/data/views.dart index a9da87dbf..76a7df12a 100644 --- a/lib/src/models/data/views.dart +++ b/lib/src/models/data/views.dart @@ -1,4 +1,5 @@ import "package:flutter/material.dart"; +import "package:flutter_resizable_container/flutter_resizable_container.dart"; import "package:rover_dashboard/data.dart"; import "package:rover_dashboard/models.dart"; @@ -106,6 +107,13 @@ class DashboardView { /// A data model for keeping track of the on-screen views. class ViewsModel extends Model { + /// The controller for the resizable row on top. + final horizontalController1 = ResizableController(shouldDispose: false); + /// The controller for the resizable row on bottom. + final horizontalController2 = ResizableController(shouldDispose: false); + /// The controller for the resizable column. + final verticalController = ResizableController(shouldDispose: false); + /// The current views on the screen. List views = [ DashboardView.cameraViews[0], @@ -122,6 +130,21 @@ class ViewsModel extends Model { super.dispose(); } + /// Resets the size of all the views. + void resetSizes() { + if (views.length == 2 && models.settings.dashboard.splitMode == SplitMode.horizontal) { + verticalController.setRatios([0.5, 0.5]); + } else if (views.length > 2) { + verticalController.setRatios([0.5, 0.5]); + } + if (views.length == 2 && models.settings.dashboard.splitMode == SplitMode.vertical) { + horizontalController1.setRatios([0.5, 0.5]); + } else if (views.length > 2) { + horizontalController1.setRatios([0.5, 0.5]); + } + if (views.length == 4) horizontalController2.setRatios([0.5, 0.5]); + } + /// Replaces the [oldView] with the [newView]. void replaceView(String oldView, DashboardView newView) { if (views.contains(newView)) { diff --git a/lib/src/models/rover/settings.dart b/lib/src/models/rover/settings.dart index eb286a605..ffe4c3f68 100644 --- a/lib/src/models/rover/settings.dart +++ b/lib/src/models/rover/settings.dart @@ -48,6 +48,7 @@ class RoverSettings extends Model { /// /// See [RoverStatus] for details. Future setStatus(RoverStatus value) async { + if (!models.rover.isConnected) return; if (value == RoverStatus.AUTONOMOUS || value == RoverStatus.IDLE) { models.rover.controller1.setMode(OperatingMode.none); models.rover.controller2.setMode(OperatingMode.none); diff --git a/lib/src/pages/home.dart b/lib/src/pages/home.dart index 7aadf6bd6..0edc7bd6e 100644 --- a/lib/src/pages/home.dart +++ b/lib/src/pages/home.dart @@ -45,6 +45,11 @@ class HomePageState extends State{ flexibleSpace: Center(child: TimerWidget()), actions: [ SocketSwitcher(), + IconButton( + icon: const Icon(Icons.aspect_ratio), + tooltip: "Reset view sizes", + onPressed: models.views.resetSizes, + ), IconButton( icon: const Icon(Icons.settings), onPressed: () => Navigator.of(context).pushNamed(Routes.settings), diff --git a/lib/src/widgets/navigation/views.dart b/lib/src/widgets/navigation/views.dart index ef49cbe18..516607e60 100644 --- a/lib/src/widgets/navigation/views.dart +++ b/lib/src/widgets/navigation/views.dart @@ -14,11 +14,16 @@ class ViewsWidget extends ReusableReactiveWidget { Widget build(BuildContext context, ViewsModel model) => switch (model.views.length) { 1 => Column(children: [Expanded(child: models.views.views.first.builder(context))]), 2 => ResizableContainer( + key: const ValueKey(2), direction: switch (models.settings.dashboard.splitMode) { SplitMode.horizontal => Axis.vertical, SplitMode.vertical => Axis.horizontal, }, dividerWidth: 5, + controller: switch (models.settings.dashboard.splitMode) { + SplitMode.horizontal => model.verticalController, + SplitMode.vertical => model.horizontalController1, + }, dividerColor: Colors.black, children: [ ResizableChildData( @@ -32,6 +37,8 @@ class ViewsWidget extends ReusableReactiveWidget { ], ), 3 || 4 => ResizableContainer( + key: const ValueKey(3), + controller: model.verticalController, direction: Axis.vertical, dividerWidth: 5, dividerColor: Colors.black, @@ -39,6 +46,8 @@ class ViewsWidget extends ReusableReactiveWidget { ResizableChildData( startingRatio: 0.5, child: ResizableContainer( + key: const ValueKey(4), + controller: model.horizontalController1, direction: Axis.horizontal, dividerWidth: 5, dividerColor: Colors.black, @@ -60,6 +69,7 @@ class ViewsWidget extends ReusableReactiveWidget { ) else ResizableChildData( startingRatio: 0.5, child: ResizableContainer( + controller: model.horizontalController2, direction: Axis.horizontal, dividerWidth: 5, dividerColor: Colors.black, diff --git a/pubspec.lock b/pubspec.lock index 661e24401..cafad4432 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -194,10 +194,11 @@ packages: flutter_resizable_container: dependency: "direct main" description: - name: flutter_resizable_container - sha256: d3f11e7b88271a00282804ee528fbd90790b2b2db340f9ff76b0504c4cdfa297 - url: "https://pub.dev" - source: hosted + path: "." + ref: controller + resolved-ref: "88f29aac86981df77dc76cf4e9d33e7fed05604d" + url: "https://github.com/Levi-Lesches/flutter_resizable_container.git" + source: git version: "0.3.0" flutter_test: dependency: "direct dev" diff --git a/pubspec.yaml b/pubspec.yaml index 262994ad4..1647a922b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,13 @@ dependencies: url_launcher: ^6.1.10 win32_gamepad: ^1.0.3 +dependency_overrides: + # Waiting for PR to merge: https://github.com/andyhorn/flutter_resizable_container/pull/5 + flutter_resizable_container: + git: + url: https://github.com/Levi-Lesches/flutter_resizable_container.git + ref: controller + # Prefer to use `flutter pub add --dev packageName` rather than modify this section by hand. dev_dependencies: flutter_launcher_icons: ^0.13.1 From 97346a280141e64cc79aedc53038b72ff36a4f8c Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Fri, 16 Feb 2024 05:27:33 -0500 Subject: [PATCH 17/17] Added Android controls --- android/app/build.gradle | 2 +- android/build.gradle | 2 +- lib/src/pages/home.dart | 25 +++--- lib/src/widgets/generic/mobile_controls.dart | 93 ++++++++++++++++++++ lib/src/widgets/navigation/views.dart | 17 +++- lib/widgets.dart | 1 + 6 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 lib/src/widgets/generic/mobile_controls.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index be6f08274..8ca3a6a6a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion flutter.compileSdkVersion + compileSdkVersion 34 ndkVersion flutter.ndkVersion compileOptions { diff --git a/android/build.gradle b/android/build.gradle index f865613ad..0a40a9865 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/lib/src/pages/home.dart b/lib/src/pages/home.dart index 0edc7bd6e..f30ae856b 100644 --- a/lib/src/pages/home.dart +++ b/lib/src/pages/home.dart @@ -1,3 +1,4 @@ +import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:rover_dashboard/data.dart"; @@ -61,15 +62,19 @@ class HomePageState extends State{ ], ), bottomNavigationBar: const Footer(), - body: Row( - children: [ - Expanded(child: ViewsWidget()), - // An AnimatedSize widget automatically shrinks the widget away - AnimatedSize( - duration: const Duration(milliseconds: 250), - child: showSidebar ? const Sidebar() : Container(), - ), - ], - ), + body: Stack(children: [ + Row( + children: [ + Expanded(child: ViewsWidget()), + // An AnimatedSize widget automatically shrinks the widget away + AnimatedSize( + duration: const Duration(milliseconds: 250), + child: showSidebar ? const Sidebar() : Container(), + ), + ], + ), + if (defaultTargetPlatform == TargetPlatform.android) + MobileControls(), + ],), ); } diff --git a/lib/src/widgets/generic/mobile_controls.dart b/lib/src/widgets/generic/mobile_controls.dart new file mode 100644 index 000000000..894de5587 --- /dev/null +++ b/lib/src/widgets/generic/mobile_controls.dart @@ -0,0 +1,93 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:rover_dashboard/data.dart"; +import "package:rover_dashboard/models.dart"; +import "package:rover_dashboard/widgets.dart"; + +/// Drive controls for mobile devices where gamepads aren't feasible. +class MobileControlsModel with ChangeNotifier { + /// The speed of the left wheels. + double left = 0; + /// The speed of the right wheels. + double right = 0; + + late Timer _timer; + + /// Starts sending messages to the rover. + MobileControlsModel() { + _timer = Timer.periodic(const Duration(milliseconds: 10), _sendSpeeds); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + void _sendSpeeds(_) { + final command1 = DriveCommand(setLeft: true, left: left); + final command2 = DriveCommand(setRight: true, right: right); + models.messages.sendMessage(command1); + models.messages.sendMessage(command2); + } + + /// Updates the left speed. + void updateLeft(double value) { + left = value; + notifyListeners(); + } + + /// Updates the right speed. + void updateRight(double value) { + right = value; + notifyListeners(); + } +} + +/// Drive controls for mobile devices where gamepads aren't feasible. +class MobileControls extends ReactiveWidget { + @override + MobileControlsModel createModel() => MobileControlsModel(); + + @override + Widget build(BuildContext context, MobileControlsModel model) => Row( + children: [ + const SizedBox(width: 24), + RotatedBox( + quarterTurns: 3, + child: SliderTheme( + data: const SliderThemeData( + trackHeight: 48, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 36), + ), + child: Slider( + onChanged: model.updateLeft, + onChangeEnd: (_) => model.updateLeft(0), + value: model.left, + min: -1, + label: model.left.toString(), + ), + ), + ), + const Spacer(), + RotatedBox( + quarterTurns: 3, + child: SliderTheme( + data: const SliderThemeData( + trackHeight: 48, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 36), + ), + child: Slider( + onChanged: model.updateRight, + onChangeEnd: (_) => model.updateRight(0), + value: model.right, + min: -1, + label: model.right.toString(), + ), + ), + ), + const SizedBox(width: 24), + ], + ); +} diff --git a/lib/src/widgets/navigation/views.dart b/lib/src/widgets/navigation/views.dart index 516607e60..e00abfd0a 100644 --- a/lib/src/widgets/navigation/views.dart +++ b/lib/src/widgets/navigation/views.dart @@ -19,7 +19,7 @@ class ViewsWidget extends ReusableReactiveWidget { SplitMode.horizontal => Axis.vertical, SplitMode.vertical => Axis.horizontal, }, - dividerWidth: 5, + dividerWidth: 8, controller: switch (models.settings.dashboard.splitMode) { SplitMode.horizontal => model.verticalController, SplitMode.vertical => model.horizontalController1, @@ -27,10 +27,12 @@ class ViewsWidget extends ReusableReactiveWidget { dividerColor: Colors.black, children: [ ResizableChildData( + minSize: 100, startingRatio: 0.5, child: models.views.views[0].builder(context), ), ResizableChildData( + minSize: 100, startingRatio: 0.5, child: models.views.views[1].builder(context), ), @@ -40,23 +42,26 @@ class ViewsWidget extends ReusableReactiveWidget { key: const ValueKey(3), controller: model.verticalController, direction: Axis.vertical, - dividerWidth: 5, + dividerWidth: 8, dividerColor: Colors.black, children: [ ResizableChildData( + minSize: 100, startingRatio: 0.5, child: ResizableContainer( key: const ValueKey(4), controller: model.horizontalController1, direction: Axis.horizontal, - dividerWidth: 5, + dividerWidth: 8, dividerColor: Colors.black, children: [ ResizableChildData( + minSize: 100, startingRatio: 0.5, child: models.views.views[0].builder(context), ), ResizableChildData( + minSize: 100, startingRatio: 0.5, child: models.views.views[1].builder(context), ), @@ -64,21 +69,25 @@ class ViewsWidget extends ReusableReactiveWidget { ), ), if (model.views.length == 3) ResizableChildData( + minSize: 100, startingRatio: 0.5, child: models.views.views[2].builder(context), ) else ResizableChildData( + minSize: 100, startingRatio: 0.5, child: ResizableContainer( controller: model.horizontalController2, direction: Axis.horizontal, - dividerWidth: 5, + dividerWidth: 8, dividerColor: Colors.black, children: [ ResizableChildData( + minSize: 100, startingRatio: 0.5, child: models.views.views[2].builder(context), ), ResizableChildData( + minSize: 100, startingRatio: 0.5, child: models.views.views[3].builder(context), ), diff --git a/lib/widgets.dart b/lib/widgets.dart index 3b10a1b61..d5b7d8a23 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -24,6 +24,7 @@ export "src/widgets/atomic/science_command.dart"; export "src/widgets/atomic/video_feed.dart"; export "src/widgets/generic/gamepad.dart"; +export "src/widgets/generic/mobile_controls.dart"; export "src/widgets/generic/reactive_widget.dart"; export "src/widgets/generic/timer.dart";