diff --git a/lib/communication/science_lab.dart b/lib/communication/science_lab.dart index bd51e2a27..f5d9535cd 100644 --- a/lib/communication/science_lab.dart +++ b/lib/communication/science_lab.dart @@ -413,4 +413,44 @@ class ScienceLab { logger.e(e); } } + + Future servo4( + double angle1, double angle2, double angle3, double angle4) async { + const int period = 10000; + const int base = 750; + const int range = 1900; + const int params = (1 << 5) | 2; + + try { + mPacketHandler.sendByte(mCommandsProto.wavegen); + + mPacketHandler.sendByte(mCommandsProto.sqr4); + + mPacketHandler.sendInt(period); + + int pulse1 = base + (angle1 * range ~/ 180); + mPacketHandler.sendInt(pulse1); + + mPacketHandler.sendInt(0); + + int pulse2 = base + (angle2 * range ~/ 180); + mPacketHandler.sendInt(pulse2); + + mPacketHandler.sendInt(0); + + int pulse3 = base + (angle3 * range ~/ 180); + mPacketHandler.sendInt(pulse3); + + mPacketHandler.sendInt(0); + + int pulse4 = base + (angle4 * range ~/ 180); + mPacketHandler.sendInt(pulse4); + + mPacketHandler.sendByte(params); + + await mPacketHandler.getAcknowledgement(); + } catch (e) { + logger.e("Error in servo4(): $e"); + } + } } diff --git a/lib/constants.dart b/lib/constants.dart index 181c8f718..c872208a9 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -208,6 +208,28 @@ String maxValue = 'Max: '; String gyroscopeTitle = "Gyroscope"; String gyroscopeAxisLabel = 'rad/s'; String noData = 'No data available'; +String degreeSymbol = '°'; +String enterAngleRange = 'Enter angle (0 - 360)'; +String errorCannotBeEmpty = 'Cannot be empty'; +String servoValidNumberRange = 'Please enter a valid number between 0 and 360'; +String ok = 'Ok'; +String roboticArm = 'Robotic Arm'; +String play = 'Play'; +String pause = 'Pause'; +String stop = 'Stop'; +String controls = 'Controls'; +String saveData = 'Save Data'; +String showGuide = 'Show Guide'; +String showLoggedDataKey = 'show_logged_data'; +String showLoggedData = 'Show Logged Data'; +String setAngle = 'Set angle for Servo'; +String angleDialog = 'AngleDialog'; +List servoLabels = [ + 'Servo 1', + 'Servo 2', + 'Servo 3', + 'Servo 4', +]; String xyPlot = 'XY Plot'; String enablePlot = 'Enable XY Plot'; String trigger = 'Trigger'; diff --git a/lib/main.dart b/lib/main.dart index 5a9f72502..b81b26920 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:pslab/view/gyroscope_screen.dart'; import 'package:pslab/view/instruments_screen.dart'; import 'package:pslab/view/luxmeter_screen.dart'; import 'package:pslab/view/oscilloscope_screen.dart'; +import 'package:pslab/view/robotic_arm_screen.dart'; import 'package:pslab/view/settings_screen.dart'; import 'package:pslab/view/about_us_screen.dart'; import 'package:pslab/view/software_licenses_screen.dart'; @@ -54,6 +55,7 @@ class MyApp extends StatelessWidget { '/softwareLicenses': (context) => const SoftwareLicensesScreen(), '/accelerometer': (context) => const AccelerometerScreen(), '/gyroscope': (context) => const GyroscopeScreen(), + '/roboticArm': (context) => const RoboticArmScreen(), '/luxmeter': (context) => const LuxMeterScreen(), '/soundmeter': (context) => const SoundMeterScreen(), }, diff --git a/lib/providers/robotic_arm_state_provider.dart b/lib/providers/robotic_arm_state_provider.dart new file mode 100644 index 000000000..4e9d92886 --- /dev/null +++ b/lib/providers/robotic_arm_state_provider.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pslab/communication/science_lab.dart'; +import 'package:pslab/others/logger_service.dart'; +import '../others/science_lab_common.dart'; + +class RoboticArmStateProvider extends ChangeNotifier { + final List servoValues = [0, 0, 0, 0]; + final int totalTimelineItems = 60; + final double scrollAmountPerTick = 120; + final List> timelineDegrees = + List.generate(60, (_) => List.filled(4, 0)); + int timelinePosition = 0; + bool isPlaying = false; + + late ScienceLab scienceLab; + Timer? _debounceTimer; + Timer? _timelineTimer; + final ScrollController timelineScrollController = ScrollController(); + + Future initialize() async { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + scienceLab = + ScienceLabCommon(ScienceLabCommon.communicationHandler).getScienceLab(); + await scienceLab.connect(); + } + + void disposeResources() { + _timelineTimer?.cancel(); + _debounceTimer?.cancel(); + timelineScrollController.dispose(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } + + void updateServoValue(int index, double value) { + servoValues[index] = value; + notifyListeners(); + _sendAllServoCommands(); + } + + void updateTimelineDegree(int timeIndex, int servoIndex, double value) { + timelineDegrees[timeIndex][servoIndex] = value; + notifyListeners(); + } + + void _sendAllServoCommands() { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 200), () async { + try { + await scienceLab.servo4( + servoValues[0], + servoValues[1], + servoValues[2], + servoValues[3], + ); + } catch (e) { + logger.e(e); + } + }); + } + + void togglePlayPause() { + if (isPlaying) { + _timelineTimer?.cancel(); + isPlaying = false; + notifyListeners(); + } else { + _timelineTimer = + Timer.periodic(const Duration(seconds: 1), (timer) async { + if (timelinePosition >= totalTimelineItems) { + stopScrolling(resetPosition: false); + return; + } + + timelineScrollController.animateTo( + timelineScrollController.offset + scrollAmountPerTick, + duration: const Duration(milliseconds: 50), + curve: Curves.easeInOut, + ); + + final angles = timelineDegrees[timelinePosition]; + + if (scienceLab.isConnected()) { + try { + await scienceLab.servo4( + angles[0], + angles[1], + angles[2], + angles[3], + ); + } catch (e) { + logger.e(e); + } + } + + timelinePosition++; + notifyListeners(); + }); + + isPlaying = true; + notifyListeners(); + } + } + + void stopScrolling({bool resetPosition = true}) { + _timelineTimer?.cancel(); + isPlaying = false; + timelinePosition = 0; + notifyListeners(); + + if (resetPosition) { + timelineScrollController.animateTo( + 0, + duration: const Duration(milliseconds: 50), + curve: Curves.easeOut, + ); + } + } +} diff --git a/lib/view/instruments_screen.dart b/lib/view/instruments_screen.dart index 913880bc4..7c6a4314a 100644 --- a/lib/view/instruments_screen.dart +++ b/lib/view/instruments_screen.dart @@ -63,6 +63,18 @@ class _InstrumentsScreenState extends State { ); } break; + case 12: + if (Navigator.canPop(context) && + ModalRoute.of(context)?.settings.name == '/roboticArm') { + Navigator.popUntil(context, ModalRoute.withName('/roboticArm')); + } else { + Navigator.pushNamedAndRemoveUntil( + context, + '/roboticArm', + (route) => route.isFirst, + ); + } + break; case 15: if (Navigator.canPop(context) && ModalRoute.of(context)?.settings.name == '/soundmeter') { diff --git a/lib/view/robotic_arm_screen.dart b/lib/view/robotic_arm_screen.dart new file mode 100644 index 000000000..72ed3d6c6 --- /dev/null +++ b/lib/view/robotic_arm_screen.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:pslab/constants.dart'; +import 'package:pslab/view/widgets/common_scaffold_widget.dart'; +import 'package:pslab/view/widgets/robotic_arm_timeline.dart'; +import '../colors.dart'; +import '../providers/robotic_arm_state_provider.dart'; +import 'widgets/servo_card.dart'; + +class RoboticArmScreen extends StatefulWidget { + const RoboticArmScreen({super.key}); + + @override + State createState() => _RoboticArmScreenState(); +} + +class _RoboticArmScreenState extends State { + late RoboticArmStateProvider provider; + + @override + void initState() { + super.initState(); + provider = RoboticArmStateProvider(); + provider.initialize(); + } + + void _showAngleInputDialog(BuildContext context, int index) { + double currentValue = provider.servoValues[index]; + final controller = + TextEditingController(text: currentValue.round().toString()); + + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: angleDialog, + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (context, _, __) { + return SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: Material( + color: Colors.transparent, + child: StatefulBuilder( + builder: (context, setStateDialog) { + void updateValue(double newVal) { + currentValue = newVal.clamp(0, 360); + controller.text = currentValue.round().toString(); + } + + return Container( + width: 240, + padding: const EdgeInsets.all(10), + margin: const EdgeInsets.only(top: 20), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: primaryRed), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$setAngle ${index + 1}', + style: TextStyle(color: primaryRed)), + const SizedBox(height: 8), + Row( + children: [ + IconButton( + icon: Icon(Icons.remove, + size: 18, color: primaryRed), + onPressed: () { + updateValue( + (double.tryParse(controller.text) ?? 0) - + 1); + setStateDialog(() {}); + }, + ), + Expanded( + child: TextField( + controller: controller, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder()), + onChanged: (val) { + final parsed = double.tryParse(val); + if (parsed != null) { + currentValue = parsed.clamp(0, 360); + setStateDialog(() {}); + } + }, + ), + ), + IconButton( + icon: + Icon(Icons.add, size: 18, color: primaryRed), + onPressed: () { + updateValue( + (double.tryParse(controller.text) ?? 0) + + 1); + setStateDialog(() {}); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(cancel)), + TextButton( + onPressed: () { + final value = double.tryParse(controller.text); + if (value != null && + value >= 0 && + value <= 360) { + setState(() { + provider.updateServoValue(index, value); + }); + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(servoValidNumberRange)), + ); + } + }, + child: Text(ok), + ), + ], + ) + ], + ), + ); + }, + ), + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: provider, + ), + ], + child: Consumer( + builder: (context, provider, _) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + final servoHeight = (screenHeight / 2.7); + + return CommonScaffold( + title: roboticArm, + actions: [ + IconButton( + icon: Icon( + provider.isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + ), + tooltip: play, + onPressed: () { + setState(() { + provider.togglePlayPause(); + }); + }, + ), + IconButton( + icon: const Icon(Icons.stop, color: Colors.white), + tooltip: stop, + onPressed: () { + setState(() { + provider.stopScrolling(resetPosition: true); + }); + }, + ), + IconButton( + icon: const Icon(Icons.tune, color: Colors.white), + tooltip: controls, + onPressed: () {}, //TODO controls + ), + IconButton( + icon: const Icon(Icons.save, color: Colors.white), + tooltip: saveData, + onPressed: () {}, //TODO + ), + IconButton( + icon: const Icon(Icons.info, color: Colors.white), + tooltip: showGuide, + onPressed: () {}, //TODO + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.white), + onSelected: (value) { + if (value == showLoggedDataKey) { + // TODO + } + }, + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + value: showLoggedDataKey, + child: Text(showLoggedData), + ), + ], + ), + ], + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: servoHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(4, (index) { + return Expanded( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 4), + child: SizedBox( + height: servoHeight, + child: ServoCard( + value: provider.servoValues[index], + label: servoLabels[index], + servoId: index, + onChanged: (val) { + setState(() { + provider.updateServoValue(index, val); + }); + }, + onTap: () => + _showAngleInputDialog(context, index), + ), + ), + ), + ); + }), + ), + ), + const SizedBox(height: 3), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: screenWidth * 5, + child: TimelineScrollView( + scrollController: provider.timelineScrollController, + timelinePosition: provider.timelinePosition, + scrollAmountPerTick: provider.scrollAmountPerTick, + timelineDegrees: provider.timelineDegrees, + onUpdate: (index, servo, value) { + setState(() { + provider.updateTimelineDegree( + index, servo, value); + }); + }, + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/view/widgets/robotic_arm_timeline.dart b/lib/view/widgets/robotic_arm_timeline.dart new file mode 100644 index 000000000..df3ee1e39 --- /dev/null +++ b/lib/view/widgets/robotic_arm_timeline.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:pslab/constants.dart'; + +import '../../colors.dart'; + +class TimelineScrollView extends StatelessWidget { + final ScrollController scrollController; + final int timelinePosition; + final double scrollAmountPerTick; + final List> timelineDegrees; + final void Function(int index, int servo, double value) onUpdate; + + const TimelineScrollView({ + super.key, + required this.scrollController, + required this.timelinePosition, + required this.scrollAmountPerTick, + required this.timelineDegrees, + required this.onUpdate, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 160, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + child: Row( + children: List.generate(60, (index) { + bool isCurrent = index == timelinePosition; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 0), + child: Column( + children: [ + Container( + width: 130, + height: 4, + color: isCurrent ? primaryRed : Colors.transparent, + ), + const SizedBox(height: 3), + ...List.generate(4, (boxIndex) { + return Padding( + padding: const EdgeInsets.all(1.0), + child: SizedBox( + width: 130, + height: 35, + child: DragTarget>( + builder: (context, candidateData, rejectedData) { + bool isHighlighted = candidateData.isNotEmpty; + return Container( + decoration: BoxDecoration( + color: isHighlighted + ? Colors.blue.withAlpha((0.3 * 255).round()) + : Colors.black, + borderRadius: BorderRadius.circular(4), + ), + padding: + const EdgeInsets.symmetric(horizontal: 2), + child: Stack( + children: [ + Positioned( + top: 0, + left: 1, + child: Text( + '${timelineDegrees[index][boxIndex].toStringAsFixed(0)}$degreeSymbol', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + ), + ), + ), + Positioned( + bottom: 0, + right: 1, + child: Text( + '${index + 1}s', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ), + ], + ), + ); + }, + onWillAcceptWithDetails: + (DragTargetDetails> + details) { + final data = details.data; + return data['servoId'] == boxIndex; + }, + onAcceptWithDetails: + (DragTargetDetails> + details) { + final data = details.data; + onUpdate(index, boxIndex, data['degree']); + }, + ), + ), + ); + }), + ], + ), + ); + }), + ), + ), + ); + } +} diff --git a/lib/view/widgets/servo_card.dart b/lib/view/widgets/servo_card.dart new file mode 100644 index 000000000..1b6d3b9fe --- /dev/null +++ b/lib/view/widgets/servo_card.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:pslab/colors.dart'; +import 'package:sleek_circular_slider/sleek_circular_slider.dart'; + +import '../../constants.dart'; + +class ServoCard extends StatelessWidget { + final double value; + final Function(double) onChanged; + final VoidCallback onTap; + final String label; + final int servoId; + + const ServoCard({ + super.key, + required this.value, + required this.onChanged, + required this.onTap, + required this.label, + required this.servoId, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade400), + ), + ), + ), + Positioned( + top: 6, + left: 8, + child: Text( + label, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ), + Positioned( + top: 8, + right: 8, + child: Draggable>( + data: { + 'servoId': servoId, + 'degree': value, + }, + feedback: Material( + color: Colors.transparent, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${value.toInt()}°', + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + childWhenDragging: + const Icon(Icons.more_vert, size: 16, color: Colors.grey), + child: const Icon(Icons.more_vert, size: 16, color: Colors.grey), + ), + ), + Positioned( + left: 0, + right: 0, + top: 25, + child: GestureDetector( + onTap: onTap, + child: Center( + child: SleekCircularSlider( + initialValue: value, + min: 0, + max: 360, + onChange: onChanged, + appearance: CircularSliderAppearance( + size: 96, + startAngle: 270, + angleRange: 360, + customWidths: CustomSliderWidths( + trackWidth: 8, + progressBarWidth: 8, + handlerSize: 14, + ), + customColors: CustomSliderColors( + trackColor: Colors.grey.shade300, + progressBarColor: primaryRed, + dotColor: Colors.blue, + ), + infoProperties: InfoProperties( + mainLabelStyle: const TextStyle(fontSize: 16), + modifier: (val) => '${val.toInt()}$degreeSymbol', + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index bf852268d..191e05cf7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: font_awesome_flutter: ^10.8.0 package_info_plus: ^8.3.0 sensors_plus: ^6.1.1 + sleek_circular_slider: ^2.1.0 gauge_indicator: ^0.4.3 light: ^4.1.0 connectivity_plus: ^6.1.4