diff --git a/assets/icons/compass_icon.png b/assets/icons/compass_icon.png
new file mode 100644
index 000000000..bd2de3294
Binary files /dev/null and b/assets/icons/compass_icon.png differ
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 3fd130c58..c3748b63f 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -30,6 +30,8 @@
Main
NSMicrophoneUsageDescription
App needs Microphone access to capture audio
+ NSMotionUsageDescription
+ This app uses motion sensors to determine compass direction.
UISupportedInterfaceOrientations
UIInterfaceOrientationPortrait
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 4c28ec9d6..508e709bd 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -323,6 +323,10 @@
"baroMeterBulletPoint2": "If you want to use the sensor BMP-180, connect the sensor to PSLab device as shown in the figure.",
"baroMeterBulletPoint3": "The above pin configuration has to be same except for the pin GND. GND is meant for Ground and any of the PSLab device GND pins can be used since they are common.",
"baroMeterBulletPoint4": "Select the sensor by going to the Configure tab from the bottom navigation bar and choose BMP-180 in the drop down menu under Select Sensor.",
+ "magnetometerError" : "Magnetometer error:",
+ "accelerometerError" : "Accelerometer error:",
+ "compassTitle": "Compass",
+ "parallelToGround": "Select axes parallel to ground",
"sharingMessage": "Sharing PSLab Data",
"delete": "Delete",
"deleteHint": "Are you sure you want to delete this file?",
@@ -361,4 +365,4 @@
"accelerometerHighLimitHint": "Please provide the maximum limit of lux value to be recorded",
"roboticArmIntro": "• A robotic arm is a programmable mechanical device that mimics the movement of a human arm.\n• It uses servo motors to control its motion, and these motors are operated using PWM signals.\n• The PSLab provides four PWM square wave generators (SQ1, SQ2, SQ3, SQ4), allowing control of up to four servo motors and enabling a robotic arm with up to four degrees of freedom.",
"roboticArmConnection": "• In the above figure, SQ1 is connected to the signal pin of the first servo motor. The servo's GND pin is connected to both the PSLab’s GND and the external power supply GND, while the VCC pin is connected to the external power supply VCC.\n• Similarly, connect the remaining servos to SQ2, SQ3, and SQ4 along with their respective GND and power supply connections.\n• Once connected, each servo can be controlled using either circular sliders for manual control or a timeline-based sequence for automated movement."
-}
\ No newline at end of file
+}
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index f5dc42539..53e899fac 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -2032,6 +2032,30 @@ abstract class AppLocalizations {
/// **'Select the sensor by going to the Configure tab from the bottom navigation bar and choose BMP-180 in the drop down menu under Select Sensor.'**
String get baroMeterBulletPoint4;
+ /// No description provided for @magnetometerError.
+ ///
+ /// In en, this message translates to:
+ /// **'Magnetometer error:'**
+ String get magnetometerError;
+
+ /// No description provided for @accelerometerError.
+ ///
+ /// In en, this message translates to:
+ /// **'Accelerometer error:'**
+ String get accelerometerError;
+
+ /// No description provided for @compassTitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Compass'**
+ String get compassTitle;
+
+ /// No description provided for @parallelToGround.
+ ///
+ /// In en, this message translates to:
+ /// **'Select axes parallel to ground'**
+ String get parallelToGround;
+
/// No description provided for @sharingMessage.
///
/// In en, this message translates to:
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 22b69ef2c..f457606be 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -1038,6 +1038,17 @@ class AppLocalizationsEn extends AppLocalizations {
'Select the sensor by going to the Configure tab from the bottom navigation bar and choose BMP-180 in the drop down menu under Select Sensor.';
@override
+ String get magnetometerError => 'Magnetometer error:';
+
+ @override
+ String get accelerometerError => 'Accelerometer error:';
+
+ @override
+ String get compassTitle => 'Compass';
+
+ @override
+ String get parallelToGround => 'Select axes parallel to ground';
+
String get sharingMessage => 'Sharing PSLab Data';
@override
diff --git a/lib/main.dart b/lib/main.dart
index f5e6e0a69..0da548da2 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -18,6 +18,7 @@ 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';
+import 'package:pslab/view/compass_screen.dart';
import 'package:pslab/theme/app_theme.dart';
import 'package:pslab/view/soundmeter_screen.dart';
import 'package:pslab/view/wave_generator_screen.dart';
@@ -60,6 +61,7 @@ class MyApp extends StatelessWidget {
routes: {
'/': (context) => const InstrumentsScreen(),
'/oscilloscope': (context) => const OscilloscopeScreen(),
+ '/compass': (context) => const CompassScreen(),
'/multimeter': (context) => const MultimeterScreen(),
'/waveGenerator': (context) => const WaveGeneratorScreen(),
'/logicAnalyzer': (context) => const LogicAnalyzerScreen(),
diff --git a/lib/providers/compass_provider.dart b/lib/providers/compass_provider.dart
new file mode 100644
index 000000000..2a5d121d2
--- /dev/null
+++ b/lib/providers/compass_provider.dart
@@ -0,0 +1,171 @@
+import 'dart:async';
+import 'dart:math';
+import 'package:flutter/material.dart';
+import 'package:sensors_plus/sensors_plus.dart';
+import 'package:flutter/foundation.dart';
+import 'package:pslab/others/logger_service.dart';
+
+import '../l10n/app_localizations.dart';
+import 'locator.dart';
+
+class CompassProvider extends ChangeNotifier {
+ AppLocalizations appLocalizations = getIt.get();
+ MagnetometerEvent _magnetometerEvent =
+ MagnetometerEvent(0, 0, 0, DateTime.now());
+ AccelerometerEvent _accelerometerEvent =
+ AccelerometerEvent(0, 0, 0, DateTime.now());
+ StreamSubscription? _magnetometerSubscription;
+ StreamSubscription? _accelerometerSubscription;
+ String _selectedAxis = 'X';
+ double _currentDegree = 0.0;
+ int _direction = 0;
+ double _smoothedHeading = 0.0;
+
+ MagnetometerEvent get magnetometerEvent => _magnetometerEvent;
+ AccelerometerEvent get accelerometerEvent => _accelerometerEvent;
+ String get selectedAxis => _selectedAxis;
+ double get currentDegree => _currentDegree;
+ int get direction => _direction;
+ double get smoothedHeading => _smoothedHeading;
+
+ void initializeSensors() {
+ _magnetometerSubscription = magnetometerEventStream().listen(
+ (event) {
+ _magnetometerEvent = event;
+ _updateCompassDirection();
+ notifyListeners();
+ },
+ onError: (error) {
+ logger.e("${appLocalizations.magnetometerError}: $error");
+ },
+ cancelOnError: false,
+ );
+
+ _accelerometerSubscription = accelerometerEventStream().listen(
+ (event) {
+ _accelerometerEvent = event;
+ _updateCompassDirection();
+ notifyListeners();
+ },
+ onError: (error) {
+ logger.e("${appLocalizations.accelerometerError}: $error");
+ },
+ cancelOnError: false,
+ );
+ }
+
+ void disposeSensors() {
+ _magnetometerSubscription?.cancel();
+ _accelerometerSubscription?.cancel();
+ }
+
+ @override
+ void dispose() {
+ disposeSensors();
+ super.dispose();
+ }
+
+ void _updateCompassDirection() {
+ double radians = _getRadiansForAxis(_selectedAxis);
+ double degrees = radians * (180 / pi);
+ if (degrees < 0) {
+ degrees += 360;
+ }
+
+ degrees = (degrees - 90) % 360;
+ if (degrees < 0) {
+ degrees += 360;
+ }
+
+ const double alpha = 0.45;
+ double angleDiff = degrees - _smoothedHeading;
+ if (angleDiff > 180) {
+ angleDiff -= 360;
+ } else if (angleDiff < -180) {
+ angleDiff += 360;
+ }
+ _smoothedHeading = _smoothedHeading + alpha * angleDiff;
+ if (_smoothedHeading >= 360) {
+ _smoothedHeading -= 360;
+ } else if (_smoothedHeading < 0) {
+ _smoothedHeading += 360;
+ }
+ switch (_selectedAxis) {
+ case 'X':
+ _currentDegree = -(_smoothedHeading * pi / 180);
+ break;
+ case 'Y':
+ _currentDegree = ((_smoothedHeading - 10) * pi / 180);
+ break;
+ case 'Z':
+ _currentDegree = -((_smoothedHeading + 90) * pi / 180);
+ break;
+ }
+ }
+
+ double _getRadiansForAxis(String axis) {
+ double ax = _accelerometerEvent.x;
+ double ay = _accelerometerEvent.y;
+ double az = _accelerometerEvent.z;
+ double mx = _magnetometerEvent.x;
+ double my = _magnetometerEvent.y;
+ double mz = _magnetometerEvent.z;
+
+ double pitch = atan2(ay, sqrt(ax * ax + az * az));
+ double roll = atan2(-ax, az);
+
+ double xH = mx * cos(pitch) + mz * sin(pitch);
+ double yH = mx * sin(roll) * sin(pitch) +
+ my * cos(roll) -
+ mz * sin(roll) * cos(pitch);
+ double zH = -mx * cos(roll) * sin(pitch) +
+ my * sin(roll) +
+ mz * cos(roll) * cos(pitch);
+
+ switch (axis) {
+ case 'X':
+ return atan2(yH, xH);
+ case 'Y':
+ return atan2(-xH, zH);
+ case 'Z':
+ return atan2(yH, -zH);
+ default:
+ return atan2(yH, xH);
+ }
+ }
+
+ double getDegreeForAxis(String axis) {
+ double radians = _getRadiansForAxis(axis);
+ double degree = radians * (180 / pi);
+
+ switch (axis) {
+ case 'X':
+ degree = (degree - 90) % 360;
+ break;
+ case 'Y':
+ degree = (-degree + 100) % 360;
+ break;
+ case 'Z':
+ degree = (degree + 90) % 360;
+ break;
+ }
+
+ return degree < 0 ? degree + 360 : degree;
+ }
+
+ void onAxisSelected(String axis) {
+ _selectedAxis = axis;
+ switch (axis) {
+ case 'X':
+ _direction = 0;
+ break;
+ case 'Y':
+ _direction = 1;
+ break;
+ case 'Z':
+ _direction = 2;
+ break;
+ }
+ notifyListeners();
+ }
+}
diff --git a/lib/view/compass_screen.dart b/lib/view/compass_screen.dart
new file mode 100644
index 000000000..07cf252b8
--- /dev/null
+++ b/lib/view/compass_screen.dart
@@ -0,0 +1,210 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:pslab/view/widgets/common_scaffold_widget.dart';
+import '../l10n/app_localizations.dart';
+import '../providers/compass_provider.dart';
+import '../providers/locator.dart';
+import '../theme/colors.dart';
+
+class CompassScreen extends StatelessWidget {
+ const CompassScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return ChangeNotifierProvider(
+ create: (_) => CompassProvider(),
+ child: const CompassScreenContent(),
+ );
+ }
+}
+
+class CompassScreenContent extends StatefulWidget {
+ const CompassScreenContent({super.key});
+
+ @override
+ State createState() => _CompassScreenContentState();
+}
+
+class _CompassScreenContentState extends State {
+ AppLocalizations appLocalizations = getIt.get();
+ static const String compassIcon = 'assets/icons/compass_icon.png';
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ context.read().initializeSensors();
+ });
+ }
+
+ @override
+ void dispose() {
+ context.read().disposeSensors();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (context, compassProvider, child) {
+ return CommonScaffold(
+ title: appLocalizations.compassTitle,
+ body: SafeArea(
+ child: Container(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ children: [
+ Expanded(
+ flex: 3,
+ child: Center(
+ child: Transform.rotate(
+ angle: compassProvider.currentDegree,
+ child: Container(
+ width: 300,
+ height: 300,
+ decoration: const BoxDecoration(
+ shape: BoxShape.circle,
+ ),
+ child: Image.asset(
+ compassIcon,
+ fit: BoxFit.contain,
+ ),
+ ),
+ ),
+ ),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: Column(
+ children: [
+ Text(
+ compassProvider
+ .getDegreeForAxis(compassProvider.selectedAxis)
+ .round()
+ .toStringAsFixed(1),
+ style: TextStyle(
+ color: blackTextColor,
+ fontSize: 32,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Colors.grey[100],
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ _buildAxisColumn(
+ 'Bx', compassProvider.magnetometerEvent.x),
+ _buildAxisColumn(
+ 'By', compassProvider.magnetometerEvent.y),
+ _buildAxisColumn(
+ 'Bz', compassProvider.magnetometerEvent.z),
+ ],
+ ),
+ const SizedBox(height: 24),
+ Text(
+ appLocalizations.parallelToGround,
+ style: TextStyle(
+ color: blackTextColor,
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ _buildAxisSelector(context, 'X', 'X axis'),
+ _buildAxisSelector(context, 'Y', 'Y axis'),
+ _buildAxisSelector(context, 'Z', 'Z axis'),
+ ],
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ ],
+ ),
+ ),
+ ),
+ );
+ });
+ }
+
+ Widget _buildAxisColumn(String label, double value) {
+ return Column(
+ children: [
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ color: blackTextColor,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.grey.shade300),
+ ),
+ child: Text(
+ value.toStringAsFixed(1),
+ style: TextStyle(
+ color: blackTextColor,
+ fontSize: 18,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildAxisSelector(BuildContext context, String axis, String label) {
+ return Consumer(
+ builder: (context, compassProvider, child) {
+ return Expanded(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Radio(
+ value: axis,
+ groupValue: compassProvider.selectedAxis,
+ onChanged: (String? value) {
+ if (value != null) {
+ compassProvider.onAxisSelected(value);
+ }
+ },
+ activeColor: radioButtonActiveColor,
+ ),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 14,
+ color: compassProvider.selectedAxis == axis
+ ? radioButtonActiveColor
+ : blackTextColor,
+ fontWeight: compassProvider.selectedAxis == axis
+ ? FontWeight.w500
+ : FontWeight.normal,
+ ),
+ ),
+ ],
+ ),
+ );
+ });
+ }
+}
diff --git a/lib/view/instruments_screen.dart b/lib/view/instruments_screen.dart
index fa27d6bde..325d146f0 100644
--- a/lib/view/instruments_screen.dart
+++ b/lib/view/instruments_screen.dart
@@ -141,6 +141,18 @@ class _InstrumentsScreenState extends State {
);
}
break;
+ case 9:
+ if (Navigator.canPop(context) &&
+ ModalRoute.of(context)?.settings.name == '/compass') {
+ Navigator.popUntil(context, ModalRoute.withName('/compass'));
+ } else {
+ Navigator.pushNamedAndRemoveUntil(
+ context,
+ '/compass',
+ (route) => route.isFirst,
+ );
+ }
+ break;
case 4:
if (Navigator.canPop(context) &&
ModalRoute.of(context)?.settings.name == '/waveGenerator') {