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 3f2278495..e9d06dc4b 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -306,6 +306,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?",
@@ -342,4 +346,4 @@
"accelerometerConfigurations" : "Accelerometer Configurations",
"accelerometerUpdatePeriodHint" : "Please provide time interval at which data will be updated",
"accelerometerHighLimitHint" : "Please provide the maximum limit of lux value to be recorded"
-}
\ No newline at end of file
+}
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 1f83d13e8..6d0879afc 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -1930,6 +1930,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 04d10402b..c0769ea5f 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -987,6 +987,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 6a7ac6458..37cfae5cc 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -17,6 +17,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 'constants.dart';
@@ -58,6 +59,7 @@ class MyApp extends StatelessWidget {
routes: {
'/': (context) => const InstrumentsScreen(),
'/oscilloscope': (context) => const OscilloscopeScreen(),
+ '/compass': (context) => const CompassScreen(),
'/multimeter': (context) => const MultimeterScreen(),
'/logicAnalyzer': (context) => const LogicAnalyzerScreen(),
'/connectDevice': (context) => const ConnectDeviceScreen(),
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 57d5b7b66..87a603f4c 100644
--- a/lib/view/instruments_screen.dart
+++ b/lib/view/instruments_screen.dart
@@ -129,6 +129,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;
default:
break;
}