diff --git a/assets/gamesir_controller.png b/assets/gamesir_controller.png new file mode 100644 index 000000000..6681e5710 Binary files /dev/null and b/assets/gamesir_controller.png differ diff --git a/lib/pages.dart b/lib/pages.dart index c76902ee7..cedfdd77d 100644 --- a/lib/pages.dart +++ b/lib/pages.dart @@ -45,6 +45,9 @@ class Routes { /// The name of the logs page. static const String logs = "Logs"; + + /// The name of the controllers page + static const String controllers = "Controllers"; /// The name of the rocks page. static const String rocks = "Rocks"; diff --git a/lib/src/models/rover/controller.dart b/lib/src/models/rover/controller.dart index ef10d2770..266e4aa5a 100644 --- a/lib/src/models/rover/controller.dart +++ b/lib/src/models/rover/controller.dart @@ -98,5 +98,6 @@ class Controller extends Model { // print(message.toProto3Json()); models.messages.sendMessage(message); } + notifyListeners(); } } diff --git a/lib/src/models/rover/controls/arm.dart b/lib/src/models/rover/controls/arm.dart index bba27ef8f..d1c70c325 100644 --- a/lib/src/models/rover/controls/arm.dart +++ b/lib/src/models/rover/controls/arm.dart @@ -38,21 +38,21 @@ class ArmControls extends RoverControls { @override List parseInputs(GamepadState state) => [ // Manual control - if (state.normalRightX.abs() > state.normalRightY.abs() && state.normalRightX != 0) + if (state.normalRightX.abs() > state.normalRightJoystickY.abs() && state.normalRightX != 0) ArmCommand(swivel: MotorCommand(moveRadians: state.normalRightX * settings.swivel)), - if (state.normalRightY.abs() > state.normalRightX.abs() && state.normalRightY != 0) - ArmCommand(shoulder: MotorCommand(moveRadians: state.normalRightY * settings.shoulder)), + if (state.normalRightJoystickY.abs() > state.normalRightX.abs() && state.normalRightJoystickY != 0) + ArmCommand(shoulder: MotorCommand(moveRadians: state.normalRightJoystickY * settings.shoulder)), if (state.normalLeftY != 0) ArmCommand(elbow: MotorCommand(moveRadians: state.normalLeftY * settings.elbow)), // The bumpers should be pseudo-IK: Move the shoulder and elbow in sync. - if (state.normalShoulder != 0) ArmCommand( - shoulder: MotorCommand(moveRadians: state.normalShoulder * settings.shoulder * -1), - elbow: MotorCommand(moveRadians: state.normalShoulder * settings.elbow), + if (state.normalShoulders != 0) ArmCommand( + shoulder: MotorCommand(moveRadians: state.normalShoulders * settings.shoulder * -1), + elbow: MotorCommand(moveRadians: state.normalShoulders * settings.elbow), ), // Gripper if (state.normalDpadY != 0) GripperCommand(lift: MotorCommand(moveRadians: state.normalDpadY * settings.lift)), if (state.normalDpadX != 0) GripperCommand(rotate: MotorCommand(moveRadians: state.normalDpadX * settings.rotate)), - if (state.normalTrigger != 0) GripperCommand(pinch: MotorCommand(moveRadians: state.normalTrigger * settings.pinch)), + if (state.normalTriggers != 0) GripperCommand(pinch: MotorCommand(moveRadians: state.normalTriggers * settings.pinch)), // Custom actions if (state.buttonA && !isAPressed) () { isAPressed = true; return GripperCommand(open: true); }(), diff --git a/lib/src/models/rover/controls/camera.dart b/lib/src/models/rover/controls/camera.dart index d729e208b..25b10b668 100644 --- a/lib/src/models/rover/controls/camera.dart +++ b/lib/src/models/rover/controls/camera.dart @@ -44,7 +44,7 @@ class CameraControls extends RoverControls { final newFrontSwivel = state.normalLeftX; final newFrontTilt = state.normalLeftY; final newRearSwivel = state.normalRightX; - final newRearTilt = -1 * state.normalRightY; + final newRearTilt = -1 * state.normalRightJoystickY; if (newFrontSwivel.abs() >= 0.05 || newFrontTilt.abs() >= 0.05) { // Update the front camera. Now, choose which axis if (newFrontSwivel.abs() > newFrontTilt.abs()) { diff --git a/lib/src/models/rover/controls/modern_drive.dart b/lib/src/models/rover/controls/modern_drive.dart index 8b4b8d744..d582c2518 100644 --- a/lib/src/models/rover/controls/modern_drive.dart +++ b/lib/src/models/rover/controls/modern_drive.dart @@ -50,7 +50,7 @@ class ModernDriveControls extends RoverControls { /// Gets all commands for the wheels based on the gamepad state. List getWheelCommands(GamepadState state) { - final speed = state.normalTrigger; // sum of both triggers, [-1, 1] + final speed = state.normalTriggers; // sum of both triggers, [-1, 1] if (speed == 0) { final left = state.normalLeftX; final right = state.normalLeftX; @@ -99,7 +99,7 @@ class ModernDriveControls extends RoverControls { final newFrontSwivel = state.normalDpadX; final newFrontTilt = state.normalDpadY; final newRearSwivel = state.normalRightX; - final newRearTilt = state.normalRightY; + final newRearTilt = state.normalRightJoystickY; if (newFrontSwivel.abs() >= 0.05 || newFrontTilt.abs() >= 0.05) { // Update the front camera. Now, choose which axis if (newFrontSwivel.abs() > newFrontTilt.abs()) { diff --git a/lib/src/models/rover/controls/tank_drive.dart b/lib/src/models/rover/controls/tank_drive.dart index 1acbce2eb..6bbe0fd2e 100644 --- a/lib/src/models/rover/controls/tank_drive.dart +++ b/lib/src/models/rover/controls/tank_drive.dart @@ -30,7 +30,7 @@ class DriveControls extends RoverControls { List parseInputs(GamepadState state) => [ DriveCommand(throttle: throttle, setThrottle: true), DriveCommand(setLeft: true, left: state.normalLeftY), - DriveCommand(setRight: true, right: -1*state.normalRightY), + DriveCommand(setRight: true, right: -1*state.normalRightJoystickY), ]; @override diff --git a/lib/src/pages/controller.dart b/lib/src/pages/controller.dart new file mode 100644 index 000000000..fc763dd51 --- /dev/null +++ b/lib/src/pages/controller.dart @@ -0,0 +1,90 @@ +import "package:flutter/material.dart"; +import "package:rover_dashboard/models.dart"; +import "package:rover_dashboard/widgets.dart"; + +import "controllers/controller.dart"; + +/// A view model to select and listen to a gamepad. +class ControllersViewModel with ChangeNotifier { + /// The gamepad to listen to. + Controller selectedController = models.rover.controller1; + + /// Starts listening to the gamepad. + ControllersViewModel() { + selectedController.addListener(notifyListeners); + } + + @override + void dispose() { + selectedController.removeListener(notifyListeners); + super.dispose(); + } + + /// Changes which controller is being listened to. + void setController(Controller? value) { + if (value == null) return; + selectedController.removeListener(notifyListeners); + selectedController = value; + selectedController.addListener(notifyListeners); + notifyListeners(); + } +} + +/// The UI Page to display the controller status +class ControllersPage extends ReactiveWidget { + /// The index of this view. + final int index; + + /// Const constructor for [ControllersPage] + const ControllersPage({required this.index, super.key}); + + @override + ControllersViewModel createModel() => ControllersViewModel(); + + @override + Widget build(BuildContext context, ControllersViewModel model) => Column( + children: [ + const SizedBox(height: 16), + Row( + children: [ + const Spacer(), + const Text("Controller: "), + DropdownButton( + value: model.selectedController, + onChanged: model.setController, + items: [ + DropdownMenuItem( + value: models.rover.controller1, + child: const Text("Controller 1"), + ), + DropdownMenuItem( + value: models.rover.controller2, + child: const Text("Controller 2"), + ), + DropdownMenuItem( + value: models.rover.controller3, + child: const Text("Controller 3"), + ), + ], + ), + const SizedBox(width: 8), + FilledButton( + onPressed: model.selectedController.isConnected + ? model.selectedController.gamepad.pulse : null, + child: const Text("Vibrate"), + ), + const Spacer(), + ViewsSelector(index: index), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(10), + child: Center( + child: ControllerWidget(model.selectedController), + ), + ), + ), + ], + ); +} diff --git a/lib/src/pages/controllers/button.dart b/lib/src/pages/controllers/button.dart new file mode 100644 index 000000000..fc90f4864 --- /dev/null +++ b/lib/src/pages/controllers/button.dart @@ -0,0 +1,35 @@ +import "package:flutter/material.dart"; + +/// Represents a button that is pressed or not. +class ControllerButton extends StatelessWidget { + /// The radius with which to draw this button. + final double radius; + + /// How thick the border should be. + final double borderWidth; + + /// Whether the button is pressed or not. + final bool isPressed; + + /// The color of the button. + final Color color; + + /// Draws a small circle to represent a button that can be pressed. + const ControllerButton({ + required this.isPressed, + required this.radius, + required this.borderWidth, + required this.color, + }); + + @override + Widget build(BuildContext context) => Container( + width: radius, + height: radius, + decoration: BoxDecoration( + color: isPressed ? color : null, + shape: BoxShape.circle, + border: Border.all(color: color, width: borderWidth), + ), + ); +} diff --git a/lib/src/pages/controllers/constants.dart b/lib/src/pages/controllers/constants.dart new file mode 100644 index 000000000..3ad6c0a37 --- /dev/null +++ b/lib/src/pages/controllers/constants.dart @@ -0,0 +1,73 @@ +import "package:flutter/material.dart"; + +/// The color to fill in all gamepad buttons with. +const gamepadColor = Colors.blue; + +/// The size of the controller image. Useful for overlaying elements on the picture. +const Size imageSize = Size(905, 568); + +/// The radius for a button. +const double normalButtonRadius = 40; + +/// The radius for a joystick. +const double normalJoystickRadius = 70; + +/// The furthest a joystick can be off its center. +const double joystickMaxOffset = 40; + +/// The width of the trigger bars. +const double normalTriggerWidth = 30; + +/// The height of hte trigger bars. +const double normalTriggerHeight = 80; + +/// The thickness of the trigger bars. +const double normalTriggerOutline = 10; + +/// The position of the A button on the image. +const Offset buttonA = Offset(727, 230); + +/// The position of the B button on the image. +const Offset buttonB = Offset(784, 173); + +/// The position of the X button on the image. +const Offset buttonX = Offset(670, 173); + +/// The position of the Y button on the image. +const Offset buttonY = Offset(727, 117); + +/// The position of the left bumper/shoulder on the image. +const Offset leftBumper = Offset(186, 16); + +/// The position of the right bumper/shoulder on the image. +const Offset rightBumper = Offset(726, 16); + +/// The position of the left trigger on the image. +const Offset leftTrigger = Offset(40, 35); + +/// The position of the right trigger on the image. +const Offset rightTrigger = Offset(872, 35); + +/// The position of the select button on the image. +const Offset select = Offset(349, 112); + +/// The position of the start button on the image. +const Offset start = Offset(558, 112); + +/// The position of the up arrow button on the image. +const Offset dPadUp = Offset(180, 122); + +/// The position of the down arrow button on the image. +const Offset dPadDown = Offset(180, 221); + +/// The position of the left arrow button on the image. +const Offset dPadLeft = Offset(125, 175); + +/// The position of the right arrow button on the image. +const Offset dPadRight = Offset(227, 175); + +/// The position of the left joystick on the image. +const Offset leftStick = Offset(289, 295); + +/// The position of the right joystic on the image. +const Offset rightStick = Offset(616, 295); diff --git a/lib/src/pages/controllers/controller.dart b/lib/src/pages/controllers/controller.dart new file mode 100644 index 000000000..a9ac51c35 --- /dev/null +++ b/lib/src/pages/controllers/controller.dart @@ -0,0 +1,180 @@ +import "dart:math"; + +import "package:flutter/material.dart"; +import "package:rover_dashboard/models.dart"; +import "package:rover_dashboard/services.dart"; +import "package:rover_dashboard/widgets.dart"; + +import "button.dart"; +import "constants.dart"; + +/// A widget that shows all button presses and analog inputs overlaid on an image of a controller. +class ControllerWidget extends ReusableReactiveWidget { + /// Const constructor for Controller Widget + const ControllerWidget(super.model); + + double _getScaledValue(double normalValue, Size widgetSize) => + (_getBackgroundFitWidth(widgetSize) / imageSize.width) * normalValue; + + double _getBackgroundFitWidth(Size widgetSize) { + var fitWidth = widgetSize.width; + var fitHeight = widgetSize.height; + + if (imageSize.width < widgetSize.width && imageSize.height < widgetSize.height) { + fitWidth = imageSize.width; + fitHeight = imageSize.height; + } + + return min(fitWidth, fitHeight / (imageSize.height / imageSize.width)); + } + + Offset _getPositionedOffset({ + required Offset offsetOnImage, + required Size widgetSize, + double radius = normalButtonRadius, + }) { + final scaleFactor = _getBackgroundFitWidth(widgetSize) / imageSize.width; + final xFromCenter = offsetOnImage.dx - radius / 2; + final yFromCenter = offsetOnImage.dy - radius / 2; + + return Offset(xFromCenter, yFromCenter) * scaleFactor; + } + + // This can't be its own widget since it has to be embedded into the stack for the position to work + Widget _controllerJoystick({ + required double x, + required double y, + required Offset offsetOnImage, + required Size widgetSize, + }) { + final scaleFactor = _getBackgroundFitWidth(widgetSize) / imageSize.width; + + final joystickRadius = normalJoystickRadius * scaleFactor; + final maxOffset = joystickMaxOffset * scaleFactor; + + final xFromCenter = offsetOnImage.dx - normalJoystickRadius / 2; + final yFromCenter = offsetOnImage.dy - normalJoystickRadius / 2; + + return Positioned( + left: xFromCenter * scaleFactor + maxOffset * x, + top: yFromCenter * scaleFactor + maxOffset * y, + child: Container( + width: joystickRadius, + height: joystickRadius, + decoration: BoxDecoration( + color: gamepadColor, + borderRadius: BorderRadius.circular(10000), + ), + ), + ); + } + + Widget _buttonWidget({ + required Offset offset, + required double buttonRadius, + required double outlineWidth, + bool? value, + }) => Positioned( + left: offset.dx, + top: offset.dy, + child: ControllerButton( + isPressed: value ?? false, + radius: buttonRadius, + borderWidth: outlineWidth, + color: gamepadColor, + ), + ); + + Widget _triggerWidget({ + required Offset offset, + required double? value, + required Size size, + }) { + final scaleFactor = _getBackgroundFitWidth(size) / imageSize.width; + final triggerWidth = normalTriggerWidth * scaleFactor; + final triggerHeight = normalTriggerHeight * scaleFactor; + final borderWidth = normalTriggerOutline * scaleFactor; + return Positioned( + left: offset.dx, + top: offset.dy, + child: Container( + width: triggerWidth, + height: triggerHeight, + alignment: Alignment.bottomCenter, + decoration: BoxDecoration( + border: Border.all( + color: gamepadColor, + width: borderWidth, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: Container( + width: triggerWidth, + height: (value ?? 0) * triggerHeight, + color: gamepadColor, + ), + ), + ); + } + + Widget _buildGamepad(BuildContext context, Size widgetSize, Controller model) { + final buttonRadius = _getScaledValue(normalButtonRadius, widgetSize); + final outlineWidth = _getScaledValue(7.5, widgetSize); + final state = model.gamepad.getState(); + + final aOffset = _getPositionedOffset(offsetOnImage: buttonA, widgetSize: widgetSize); + final bOffset = _getPositionedOffset(offsetOnImage: buttonB, widgetSize: widgetSize); + final xOffset = _getPositionedOffset(offsetOnImage: buttonX, widgetSize: widgetSize); + final yOffset = _getPositionedOffset(offsetOnImage: buttonY, widgetSize: widgetSize); + final lbOffset = _getPositionedOffset(offsetOnImage: leftBumper, widgetSize: widgetSize); + final rbOffset = _getPositionedOffset(offsetOnImage: rightBumper, widgetSize: widgetSize); + final ltOffset = _getPositionedOffset(offsetOnImage: leftTrigger, widgetSize: widgetSize); + final rtOffset = _getPositionedOffset(offsetOnImage: rightTrigger, widgetSize: widgetSize); + final startOffset = _getPositionedOffset(offsetOnImage: start, widgetSize: widgetSize); + final selectOffset = _getPositionedOffset(offsetOnImage: select, widgetSize: widgetSize); + final dPadUpOffset = _getPositionedOffset(offsetOnImage: dPadUp, widgetSize: widgetSize); + final dPadDownOffset = _getPositionedOffset(offsetOnImage: dPadDown, widgetSize: widgetSize); + final dPadLeftOffset = _getPositionedOffset(offsetOnImage: dPadLeft, widgetSize: widgetSize); + final dPadRightOffset = _getPositionedOffset(offsetOnImage: dPadRight, widgetSize: widgetSize); + + return Opacity( + opacity: model.isConnected ? 1 : 0.50, + child: Stack( + children: [ + Image.asset("assets/gamesir_controller.png", fit: BoxFit.contain), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: aOffset, value: state?.buttonA), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: bOffset, value: state?.buttonB), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: xOffset, value: state?.buttonX), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: yOffset, value: state?.buttonY), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: lbOffset, value: state?.leftShoulder), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: rbOffset, value: state?.rightShoulder), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: startOffset, value: state?.buttonStart), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: selectOffset, value: state?.buttonBack), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: dPadUpOffset, value: state?.dpadUp), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: dPadDownOffset, value: state?.dpadDown), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: dPadLeftOffset, value: state?.dpadLeft), + _buttonWidget(buttonRadius: buttonRadius, outlineWidth: outlineWidth, offset: dPadRightOffset, value: state?.dpadRight), + _triggerWidget(size: widgetSize, offset: ltOffset, value: state?.normalLeftTrigger), + _triggerWidget(size: widgetSize, offset: rtOffset, value: state?.normalRightTrigger), + _controllerJoystick( + x: state?.normalLeftX ?? 0, + y: -1 * (state?.normalLeftY ?? 0), + offsetOnImage: leftStick, + widgetSize: widgetSize, + ), + _controllerJoystick( + x: state?.normalRightX ?? 0, + y: -1 * (state?.normalRightY ?? 0), + offsetOnImage: rightStick, + widgetSize: widgetSize, + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context, Controller model) => LayoutBuilder( + builder: (context, constraints) => _buildGamepad(context, constraints.biggest, model), + ); +} diff --git a/lib/src/pages/view.dart b/lib/src/pages/view.dart index df41b66a3..f6c600d42 100644 --- a/lib/src/pages/view.dart +++ b/lib/src/pages/view.dart @@ -4,6 +4,7 @@ import "package:flutter/material.dart"; import "package:rover_dashboard/data.dart"; import "package:rover_dashboard/models.dart"; import "package:rover_dashboard/pages.dart"; +import "package:rover_dashboard/src/pages/controller.dart"; import "package:rover_dashboard/widgets.dart"; /// A function that builds a view of the given index. @@ -120,6 +121,11 @@ class DashboardView { iconFunc: () => Icon(Icons.landslide, color: Colors.black.withOpacity(0.5)), builder: (context, index) => RocksPage(index: index), ), + DashboardView( + name: Routes.controllers, + iconFunc: () => Icon(Icons.sports_esports, color: Colors.black.withOpacity(0.5)), + builder: (context, index) => ControllersPage(index: index), + ), ]; /// A blank view. diff --git a/lib/src/services/gamepad/sdl.dart b/lib/src/services/gamepad/sdl.dart index 6bf9d3a1d..549e1d336 100644 --- a/lib/src/services/gamepad/sdl.dart +++ b/lib/src/services/gamepad/sdl.dart @@ -44,25 +44,5 @@ class DesktopGamepad extends Gamepad { bool get isConnected => _sdl.isConnected; @override - GamepadState getState() { - final state = _sdl.getState(); - return GamepadState( - buttonA: state.buttonA, - buttonB: state.buttonB, - buttonX: state.buttonX, - buttonY: state.buttonY, - buttonBack: state.buttonBack, - buttonStart: state.buttonStart, - normalDpadX: state.normalDpadX.toDouble(), - normalDpadY: state.normalDpadY.toDouble(), - normalLeftX: state.normalLeftJoystickX, - // These Y values are flipped because sdl_gamepad follows the standard convention, - // where positive means the joystick is moving towards the user (down). - normalLeftY: -state.normalLeftJoystickY, - normalRightX: state.normalRightJoystickX, - normalRightY: -state.normalRightJoystickY, - normalShoulder: state.normalShoulders.toDouble(), - normalTrigger: state.normalTriggers, - ); - } + GamepadState getState() => _sdl.getState(); } diff --git a/lib/src/services/gamepad/state.dart b/lib/src/services/gamepad/state.dart index 5f0e1ec74..6fafdce3e 100644 --- a/lib/src/services/gamepad/state.dart +++ b/lib/src/services/gamepad/state.dart @@ -1,3 +1,5 @@ +import "package:flutter_sdl_gamepad/flutter_sdl_gamepad.dart" as sdl; + /// The battery level of a gamepad. enum GamepadBatteryLevel { /// The battery is running low. @@ -26,84 +28,25 @@ enum GamepadBatteryLevel { /// The complete state of a gamepad. /// -/// A "normal" value means a value that is linked to two buttons. For example, both triggers -/// contribute to the [normalTrigger] value, so if the left was pressed less than the right, -/// the "normalized" result would be a small positive value. In general, the normal values range -/// from -1.0 to +1.0, inclusive, with -1 meaning all the way to one side, +1 to the other, and -/// 0 indicates that neither button is pressed. -/// -/// For digital buttons, a normalized value will only ever be -1, 0, or +1. For analog inputs, -/// including pressure-sensitive triggers, the value will be in the range [-1.0, +1.0]. -class GamepadState { - /// Whether the A button was pressed. - final bool buttonA; - - /// Whether the B button was pressed. - final bool buttonB; - - /// Whether the X button was pressed. - final bool buttonX; - - /// Whether the Y button was pressed. - final bool buttonY; - - /// Whether the Back or Select button was pressed. - final bool buttonBack; - - /// Whether the Start or Options button was pressed. - final bool buttonStart; - - /// A normalized reading of the triggers. - final double normalTrigger; - - /// A normalized reading of the shoulder buttons. - final double normalShoulder; +/// Acts as a wrapper around [sdl.GamepadState] to allow backwards compatibility with older +/// rover gamepad APIs +typedef GamepadState = sdl.GamepadState; +/// An extension on [GamepadState] to allow for backwards compatibility with +/// the rover joystick direction system +extension RoverGamepadState on GamepadState { /// A normalized reading of the left joystick's X-axis. - final double normalLeftX; + double get normalLeftX => normalLeftJoystickX; /// A normalized reading of the left joystick's Y-axis. - final double normalLeftY; - - /// A normalized reading of the right joystick's X-axis. - final double normalRightX; + double get normalLeftY => -normalLeftJoystickY; /// A normalized reading of the right joystick's X-axis. - final double normalRightY; - - /// A normalized reading of the D-pad's X-axis. - final double normalDpadX; - - /// A normalized reading of the D-pad's X-axis. - final double normalDpadY; + double get normalRightX => normalRightJoystickX; - /// Creates a new representation of the gamepad state. - const GamepadState({ - required this.buttonA, - required this.buttonB, - required this.buttonX, - required this.buttonY, - required this.buttonBack, - required this.buttonStart, - required this.normalTrigger, - required this.normalShoulder, - required this.normalLeftX, - required this.normalLeftY, - required this.normalRightX, - required this.normalRightY, - required this.normalDpadX, - required this.normalDpadY, - }); + /// A normalized reading of the right joystick's Y-axis. + double get normalRightY => -normalRightJoystickY; - /// Whether the left shoulder is being pressed. - bool get leftShoulder => normalShoulder < 0; - - /// Whether the right shoulder is being pressed. - bool get rightShoulder => normalShoulder > 0; - - /// Whether the D-pad's down button is being pressed. - bool get dpadDown => normalDpadY < 0; - - /// Whether the D-pad's up button is being pressed. - bool get dpadUp => normalDpadY > 0; + /// A normalized reading of the shoulder buttons. + double get normalShoulder => normalShoulders.toDouble(); } diff --git a/lib/src/widgets/generic/reactive_widget.dart b/lib/src/widgets/generic/reactive_widget.dart index 3b28466ee..619020974 100644 --- a/lib/src/widgets/generic/reactive_widget.dart +++ b/lib/src/widgets/generic/reactive_widget.dart @@ -1,7 +1,7 @@ import "package:flutter/material.dart"; -/// A widget that listens to a [ChangeNotifier] (called the view model) and updates when it does. -/// +/// 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 { @@ -9,8 +9,8 @@ abstract class ReactiveWidgetInterface extends Statefu 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. - /// + /// 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 @@ -23,8 +23,8 @@ 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. - /// + /// 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) { } @@ -43,7 +43,7 @@ abstract class ReactiveWidget extends ReactiveWidgetIn bool get shouldDispose => true; } -/// A [ReactiveWidgetInterface] that "borrows" a view model and does not dispose of it. +/// 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; @@ -60,7 +60,7 @@ abstract class ReusableReactiveWidget extends Reactive /// A state for [ReactiveWidget] that manages the [model]. class ReactiveWidgetState extends State>{ /// The model to listen to. - late final T model; + late T model; @override void initState() { @@ -79,6 +79,16 @@ class ReactiveWidgetState extends State oldWidget) { widget.didUpdateWidget(oldWidget, model); + if (oldWidget is ReusableReactiveWidget && widget is ReusableReactiveWidget) { + final newModel = (widget as ReusableReactiveWidget).model; + if (model != newModel) { + // Stop listening to the old one, listen to the new one. + // Don't dispose of the old one since it's reusable. + model.removeListener(listener); + model = newModel; + model.addListener(listener); + } + } super.didUpdateWidget(oldWidget); }