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 9c93f6af2..20f0f42c4 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. NSPhotoLibraryUsageDescription App needs access to photo library UISupportedInterfaceOrientations diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ab532dc6c..1c7bf511a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -323,6 +323,12 @@ "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", "thermometerTitle" : "Thermometer", "thermometerIntro" : "Thermometer instrument is used to measure ambient temprature. It can be measured using inbuilt ambient temprature sensor or through SHT21.", "celsius": "°C", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b2ccb018a..eed713b84 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 @thermometerTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3d5b31d41..6fc2a349f 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 thermometerTitle => 'Thermometer'; @override diff --git a/lib/main.dart b/lib/main.dart index f2d7b29d8..fcd2861fc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'package:pslab/view/sensors_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/thermometer_screen.dart'; @@ -70,6 +71,7 @@ class MyApp extends StatelessWidget { '/waveGenerator': (context) => const WaveGeneratorScreen(), '/logicAnalyzer': (context) => const LogicAnalyzerScreen(), '/powerSource': (context) => const PowerSourceScreen(), + '/compass': (context) => const CompassScreen(), '/connectDevice': (context) => const ConnectDeviceScreen(), '/faq': (context) => FAQScreen(), '/settings': (context) => const SettingsScreen(), 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 dddc5cd2d..bdb22da0a 100644 --- a/lib/view/instruments_screen.dart +++ b/lib/view/instruments_screen.dart @@ -165,6 +165,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') {