diff --git a/open_earable/ios/Runner/Info.plist b/open_earable/ios/Runner/Info.plist
index f5e62dd..18d65a8 100644
--- a/open_earable/ios/Runner/Info.plist
+++ b/open_earable/ios/Runner/Info.plist
@@ -1,66 +1,61 @@
-
- CADisableMinimumFrameDurationOnPhone
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleDisplayName
- OpenEarable
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- OpenEarable
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- $(FLUTTER_BUILD_NAME)
- CFBundleSignature
- ????
- CFBundleVersion
- $(FLUTTER_BUILD_NUMBER)
- ITSAppUsesNonExemptEncryption
-
- LSApplicationCategoryType
- aps-environment
- LSRequiresIPhoneOS
-
- NSBluetoothAlwaysUsageDescription
- This app uses bluetooth to connect to earable devices
- NSBluetoothPeripheralUsageDescription
- This app uses bluetooth to connect to earable devices
- UIApplicationSupportsIndirectInputEvents
-
- UIBackgroundModes
-
- fetch
- remote-notification
-
- UILaunchStoryboardName
- LaunchScreen
- UIMainStoryboardFile
- Main
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UIViewControllerBasedStatusBarAppearance
-
- UIStatusBarHidden
-
-
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ OpenEarable
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ OpenEarable
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ ITSAppUsesNonExemptEncryption
+
+ LSApplicationCategoryType
+ aps-environment
+ LSRequiresIPhoneOS
+
+ NSBluetoothAlwaysUsageDescription
+ This app uses bluetooth to connect to earable devices
+ NSBluetoothPeripheralUsageDescription
+ This app uses bluetooth to connect to earable devices
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UIStatusBarHidden
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIViewControllerBasedStatusBarAppearance
+
+
diff --git a/open_earable/ios/Runner/Runner.entitlements b/open_earable/ios/Runner/Runner.entitlements
index 903def2..0c67376 100644
--- a/open_earable/ios/Runner/Runner.entitlements
+++ b/open_earable/ios/Runner/Runner.entitlements
@@ -1,8 +1,5 @@
-
- aps-environment
- development
-
+
diff --git a/open_earable/ios/Runner/RunnerDebug.entitlements b/open_earable/ios/Runner/RunnerDebug.entitlements
index 903def2..0c67376 100644
--- a/open_earable/ios/Runner/RunnerDebug.entitlements
+++ b/open_earable/ios/Runner/RunnerDebug.entitlements
@@ -1,8 +1,5 @@
-
- aps-environment
- development
-
+
diff --git a/open_earable/lib/apps/jump_height_test/jump_height_chart.dart b/open_earable/lib/apps/jump_height_test/jump_height_chart.dart
new file mode 100644
index 0000000..41b1652
--- /dev/null
+++ b/open_earable/lib/apps/jump_height_test/jump_height_chart.dart
@@ -0,0 +1,372 @@
+import 'dart:async';
+
+import 'package:open_earable_flutter/src/open_earable_flutter.dart';
+import 'package:flutter/material.dart';
+import 'package:charts_flutter/flutter.dart' as charts;
+import 'package:simple_kalman/simple_kalman.dart';
+import 'package:collection/collection.dart';
+import 'dart:math';
+import 'dart:core';
+
+/// A class representing a Chart for Jump Height.
+class JumpHeightChart extends StatefulWidget {
+ /// The OpenEarable object.
+ final OpenEarable _openEarable;
+ /// The title of the chart.
+ final String _title;
+
+ /// Constructs a JumpHeightChart object with an OpenEarable object and a title.
+ JumpHeightChart(this._openEarable, this._title);
+
+ @override
+ _JumpHeightChartState createState() => _JumpHeightChartState(_openEarable, _title);
+}
+
+/// A class representing the state of a JumpHeightChart.
+class _JumpHeightChartState extends State {
+ /// The OpenEarable object.
+ final OpenEarable _openEarable;
+ /// The title of the chart.
+ final String _title;
+ /// The data of the chart.
+ late List _data;
+ /// The subscription to the data.
+ StreamSubscription? _dataSubscription;
+ /// The minimum x value of the chart.
+ late int _minX = 0;
+ /// The maximum x value of the chart.
+ late int _maxX = 0;
+ /// The colors of the chart.
+ late List colors;
+ /// The series of the chart.
+ List> seriesList = [];
+ /// The minimum y value of the chart.
+ late double _minY;
+ /// The maximum y value of the chart.
+ late double _maxY;
+ /// The error measure of the Kalman filter.
+ final _errorMeasureAcc = 5.0;
+ /// The Kalman filter for the x value.
+ late SimpleKalman _kalmanX;
+ /// The Kalman filter for the y value.
+ late SimpleKalman _kalmanY;
+ /// The Kalman filter for the z value.
+ late SimpleKalman _kalmanZ;
+ /// The number of datapoints to display on the chart.
+ int _numDatapoints = 200;
+
+ /// The velocity of the device.
+ double _velocity = 0.0;
+ /// Sampling rate time slice (inverse of frequency).
+ double _timeSlice = 1.0 / 30.0;
+ /// Standard gravity in m/s^2.
+ double _gravity = 9.81;
+ /// Pitch angle in radians.
+ double _pitch = 0.0;
+ /// The height of the jump.
+ double _height = 0.0;
+
+ /// Constructs a _JumpHeightChartState object with an OpenEarable object and a title.
+ _JumpHeightChartState(this._openEarable, this._title);
+
+ /// Sets up the listeners for the data.
+ _setupListeners() {
+ _kalmanX = SimpleKalman(
+ errorMeasure: _errorMeasureAcc,
+ errorEstimate: _errorMeasureAcc,
+ q: 0.9);
+ _kalmanY = SimpleKalman(
+ errorMeasure: _errorMeasureAcc,
+ errorEstimate: _errorMeasureAcc,
+ q: 0.9);
+ _kalmanZ = SimpleKalman(
+ errorMeasure: _errorMeasureAcc,
+ errorEstimate: _errorMeasureAcc,
+ q: 0.9);
+ _dataSubscription =
+ _openEarable.sensorManager.subscribeToSensorData(0).listen((data) {
+ int timestamp = data["timestamp"];
+ _pitch = data["EULER"]["PITCH"];
+
+ XYZValue rawAccData = XYZValue(
+ timestamp: timestamp,
+ x: data["ACC"]["X"],
+ y: data["ACC"]["Y"],
+ z: data["ACC"]["Z"],
+ units: {"X": "m/s²", "Y": "m/s²", "Z": "m/s²"}
+ );
+ XYZValue filteredAccData = XYZValue(
+ timestamp: timestamp,
+ x: _kalmanX.filtered(data["ACC"]["X"]),
+ y: _kalmanY.filtered(data["ACC"]["Y"]),
+ z: _kalmanZ.filtered(data["ACC"]["Z"]),
+ units: {"X": "m/s²", "Y": "m/s²", "Z": "m/s²"}
+ );
+
+ switch (_title) {
+ case "Height Data":
+ DataValue height = _calculateHeightData(filteredAccData);
+ _updateData(height);
+ break;
+ case "Raw Acceleration Data":
+ _updateData(rawAccData);
+ break;
+ case "Filtered Acceleration Data":
+ _updateData(filteredAccData);
+ break;
+ default:
+ throw ArgumentError("Invalid tab title.");
+ }
+ });
+ }
+
+ /// Calculates the height of the jump.
+ DataValue _calculateHeightData(XYZValue accValue) {
+ // Subtract gravity to get acceleration due to movement.
+ double currentAcc = accValue._z * cos(_pitch) + accValue._x * sin(_pitch) - _gravity;
+
+ double threshold = 0.3;
+ double accMagnitude = sqrt(accValue._x * accValue._x + accValue._y * accValue._y + accValue._z * accValue._z);
+ bool isStationary = (accMagnitude > _gravity - threshold) && (accMagnitude < _gravity + threshold);
+ // Checks if the device is stationary based on acceleration magnitude.
+ if (isStationary) {
+ _velocity = 0.0;
+ _height = 0.0;
+ } else {
+ // Integrate acceleration to get velocity.
+ _velocity += currentAcc * _timeSlice;
+
+ // Integrate velocity to get height.
+ _height += _velocity * _timeSlice;
+ }
+ // Prevent height from going negative.
+ _height = max(0, _height);
+
+ return Jump(DateTime.fromMillisecondsSinceEpoch(accValue._timestamp), _height);
+ }
+
+ /// Updates the data of the chart.
+ _updateData(DataValue value) {
+ setState(() {
+ _data.add(value);
+ _checkLength(_data);
+ DataValue? maxXYZValue = maxBy(_data, (DataValue b) => b.getMax());
+ DataValue? minXYZValue = minBy(_data, (DataValue b) => b.getMin());
+ if (maxXYZValue == null || minXYZValue == null) {
+ return;
+ }
+ double maxAbsValue =
+ max(maxXYZValue.getMax().abs(), minXYZValue.getMin().abs());
+ _maxY = maxAbsValue;
+
+ _minY = -maxAbsValue;
+ _maxX = value._timestamp;
+ _minX = _data[0]._timestamp;
+ });
+ }
+
+ /// Gets the color of the chart lines.
+ _getColor(String title) {
+ switch (title) {
+ case "Height Data":
+ // Blue, Orange, and Teal - Good for colorblindness
+ return ['#007bff', '#ff7f0e', '#2ca02c'];
+ case "Raw Acceleration Data":
+ // Purple, Magenta, and Cyan - Diverse hue and brightness
+ return ['#9467bd', '#d62728', '#17becf'];
+ case "Filtered Acceleration Data":
+ // Olive, Brown, and Navy - High contrast
+ return ['#8c564b', '#e377c2', '#1f77b4'];
+ default:
+ throw ArgumentError("Invalid tab title.");
+ }
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _data = [];
+ colors = _getColor(_title);
+ _minY = -25;
+ _maxY = 25;
+ _setupListeners();
+ }
+
+ @override
+ void dispose() {
+ super.dispose();
+ _dataSubscription?.cancel();
+ }
+
+ /// Checks the length of the data an removes the oldest data if it is too long.
+ _checkLength(data) {
+ if (data.length > _numDatapoints) {
+ data.removeRange(0, data.length - _numDatapoints);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (_title == "Height Data") {
+ seriesList = [
+ charts.Series(
+ id: 'Height (m)',
+ colorFn: (_, __) => charts.Color.fromHex(code: colors[0]),
+ domainFn: (DataValue data, _) => data._timestamp,
+ measureFn: (DataValue data, _) => (data as Jump)._height,
+ data: _data,
+ ),
+ ];
+ } else {
+ seriesList = [
+ charts.Series(
+ id: 'X${_data.isNotEmpty ? " (${_data[0]._units['X']})" : ""}',
+ colorFn: (_, __) => charts.Color.fromHex(code: colors[0]),
+ domainFn: (DataValue data, _) => data._timestamp,
+ measureFn: (DataValue data, _) => (data as XYZValue)._x,
+ data: _data,
+ ),
+ charts.Series(
+ id: 'Y${_data.isNotEmpty ? " (${_data[0]._units['Y']})" : ""}',
+ colorFn: (_, __) => charts.Color.fromHex(code: colors[1]),
+ domainFn: (DataValue data, _) => data._timestamp,
+ measureFn: (DataValue data, _) => (data as XYZValue)._y,
+ data: _data,
+ ),
+ charts.Series(
+ id: 'Z${_data.isNotEmpty ? " (${_data[0]._units['Z']})" : ""}',
+ colorFn: (_, __) => charts.Color.fromHex(code: colors[2]),
+ domainFn: (DataValue data, _) => data._timestamp,
+ measureFn: (DataValue data, _) => (data as XYZValue)._z,
+ data: _data,
+ ),
+ ];
+ }
+
+ return Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(8.0),
+ ),
+ Expanded(
+ child: charts.LineChart(
+ seriesList,
+ animate: false,
+ behaviors: [
+ charts.SeriesLegend(
+ position: charts.BehaviorPosition
+ .bottom, // To position the legend at the end (bottom). You can change this as per requirement.
+ outsideJustification: charts.OutsideJustification
+ .middleDrawArea, // To justify the position.
+ horizontalFirst: false, // To stack items horizontally.
+ desiredMaxRows:
+ 1, // Optional if you want to define max rows for the legend.
+ entryTextStyle: charts.TextStyleSpec(
+ // Optional styling for the text.
+ color: charts.Color(r: 255, g: 255, b: 255),
+ fontSize: 12,
+ ),
+ )
+ ],
+ primaryMeasureAxis: charts.NumericAxisSpec(
+ renderSpec: charts.GridlineRendererSpec(
+ labelStyle: charts.TextStyleSpec(
+ fontSize: 14,
+ color: charts.MaterialPalette.white, // Set the color here
+ ),
+ ),
+ viewport: charts.NumericExtents(_minY, _maxY),
+ ),
+ domainAxis: charts.NumericAxisSpec(
+ renderSpec: charts.GridlineRendererSpec(
+ labelStyle: charts.TextStyleSpec(
+ fontSize: 14,
+ color: charts.MaterialPalette.white, // Set the color here
+ ),
+ ),
+ viewport: charts.NumericExtents(_minX, _maxX)),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// A class representing a generic data value.
+abstract class DataValue {
+ /// The timestamp of the data.
+ final int _timestamp;
+ /// The units of the data.
+ final Map _units;
+
+ /// Returns the minimum value of the data.
+ double getMin();
+ /// Returns the maximum value of the data.
+ double getMax();
+
+ /// Constructs a DataValue object with a timestamp and units.
+ DataValue({required int timestamp, required Map units}) : _units = units, _timestamp = timestamp;
+}
+
+/// A class representing a generic XYZ value.
+class XYZValue extends DataValue {
+ /// The x value of the data.
+ final double _x;
+ /// The y value of the data.
+ final double _y;
+ /// The z value of the data.
+ final double _z;
+
+ /// Constructs a XYZValue object with a timestamp, x, y, z, and units.
+ XYZValue(
+ {required timestamp,
+ required double x,
+ required double y,
+ required double z,
+ required units})
+ : _z = z, _y = y, _x = x, super(timestamp: timestamp, units: units);
+
+ @override
+ double getMax() {
+ return max(_x, max(_y, _z));
+ }
+
+ @override
+ double getMin() {
+ return min(_x, min(_y, _z));
+ }
+
+ @override
+ String toString() {
+ return "timestamp: $_timestamp\nx: $_x, y: $_y, z: $_z";
+ }
+}
+
+/// A class representing a jump with a time and height.
+class Jump extends DataValue {
+ /// The time of the jump.
+ final DateTime _time;
+ /// The height of the jump.
+ final double _height;
+
+ /// Constructs a Jump object with a time and height.
+ Jump(DateTime time, double height)
+ : _time = time,
+ _height = height,
+ super(timestamp: time.millisecondsSinceEpoch, units: {'height': 'meters'});
+
+ @override
+ double getMin() {
+ return 0.0;
+ }
+
+ @override
+ double getMax() {
+ return _height;
+ }
+
+ @override
+ String toString() {
+ return "timestamp: ${_time.millisecondsSinceEpoch}\nheight $_height";
+ }
+}
diff --git a/open_earable/lib/apps/jump_height_test/jump_height_test.dart b/open_earable/lib/apps/jump_height_test/jump_height_test.dart
new file mode 100644
index 0000000..86c9d00
--- /dev/null
+++ b/open_earable/lib/apps/jump_height_test/jump_height_test.dart
@@ -0,0 +1,341 @@
+import 'package:flutter/material.dart';
+import 'package:open_earable/apps/jump_height_test/jump_height_chart.dart';
+import 'dart:async';
+import 'package:open_earable_flutter/src/open_earable_flutter.dart';
+import 'package:simple_kalman/simple_kalman.dart';
+import 'dart:math';
+
+/// An app that lets you test your jump height using an OpenEarable device.
+class JumpHeightTest extends StatefulWidget {
+ /// Instance of OpenEarable device.
+ final OpenEarable _openEarable;
+
+ /// Constructs a JumpHeightTest widget with a given OpenEarable device.
+ JumpHeightTest(this._openEarable);
+
+ /// Creates a state for JumpHeightTest widget.
+ _JumpHeightTestState createState() => _JumpHeightTestState(_openEarable);
+}
+
+/// State class for JumpHeightTest widget.
+class _JumpHeightTestState extends State
+ with SingleTickerProviderStateMixin {
+ /// Stores the start time of a jump test.
+ DateTime? _startOfJump;
+ /// Stores the duration of a jump test.
+ Duration _jumpDuration = Duration.zero;
+ /// Current height calculated from sensor data.
+ double _height = 0.0;
+ // List to store each jump's data.
+ List _jumpData = [];
+ // Flag to indicate if jump measurement is ongoing.
+ bool _isJumping = false;
+ /// Instance of OpenEarable device.
+ final OpenEarable _openEarable;
+ /// Flag to indicate if an OpenEarable device is connected.
+ bool _earableConnected = false;
+ /// Subscription to IMU sensor data.
+ StreamSubscription? _imuSubscription;
+ /// Stores the maximum height achieved in a jump.
+ double _maxHeight = 0.0; // Variable to keep track of maximum jump height
+ /// Error measure for Kalman filter.
+ final _errorMeasureAcc = 5.0;
+ /// Kalman filters for accelerometer data.
+ late SimpleKalman _kalmanX, _kalmanY, _kalmanZ;
+ /// Current velocity calculated from acceleration.
+ double _velocity = 0.0;
+ /// Sampling rate time slice (inverse of frequency).
+ double _timeSlice = 1 / 30.0;
+ /// Standard gravity in m/s^2.
+ double _gravity = 9.81;
+ /// X-axis acceleration.
+ double _accX = 0.0;
+ /// Y-axis acceleration.
+ double _accY = 0.0;
+ /// Z-axis acceleration.
+ double _accZ = 0.0;
+ /// Pitch angle in radians.
+ double _pitch = 0.0;
+ /// Manages the [TabBar].
+ late TabController _tabController;
+
+ /// Constructs a _JumpHeightTestState object with a given OpenEarable device.
+ _JumpHeightTestState(this._openEarable);
+
+ /// Initializes state and sets up listeners for sensor data.
+ @override
+ void initState() {
+ super.initState();
+ _tabController = TabController(vsync: this, length: 3);
+ // Set up listeners for sensor data.
+ if (_openEarable.bleManager.connected) {
+ // Set sampling rate to maximum.
+ _openEarable.sensorManager.writeSensorConfig(_buildSensorConfig());
+ // Initialize Kalman filters.
+ _initializeKalmanFilters();
+ _setupListeners();
+ _earableConnected = true;
+ }
+ }
+
+ /// Disposes IMU data subscription when the state object is removed.
+ @override
+ void dispose() {
+ super.dispose();
+ _imuSubscription?.cancel();
+ }
+
+ /// Sets up listeners to receive sensor data from the OpenEarable device.
+ _setupListeners() {
+ _imuSubscription =
+ _openEarable.sensorManager.subscribeToSensorData(0).listen((data) {
+ // Only process sensor data if jump measurement is ongoing.
+ if (!_isJumping) {
+ return;
+ }
+ setState(() {
+ _jumpDuration = DateTime.now().difference(_startOfJump!);
+ });
+ _processSensorData(data);
+ });
+ }
+
+ /// Starts the jump height measurement process.
+ /// It sets the sampling rate, initializes or resets variables, and begins listening to sensor data.
+ void _startJump() {
+ _startOfJump = DateTime.now();
+
+ setState(() {
+ // Clear data from previous jump.
+ _jumpData.clear();
+ _isJumping = true;
+ _height = 0.0;
+ _velocity = 0.0;
+ // Reset max height on starting a new jump
+ _maxHeight = 0.0;
+ });
+ }
+
+ /// Stops the jump height measurement process.
+ void _stopJump() {
+ if (_isJumping) {
+ setState(() {
+ _isJumping = false;
+ });
+ }
+ }
+
+ /// Initializes Kalman filters for accelerometer data.
+ void _initializeKalmanFilters() {
+ _kalmanX = SimpleKalman(
+ errorMeasure: _errorMeasureAcc,
+ errorEstimate: _errorMeasureAcc,
+ q: 0.9);
+ _kalmanY = SimpleKalman(
+ errorMeasure: _errorMeasureAcc,
+ errorEstimate: _errorMeasureAcc,
+ q: 0.9);
+ _kalmanZ = SimpleKalman(
+ errorMeasure: _errorMeasureAcc,
+ errorEstimate: _errorMeasureAcc,
+ q: 0.9);
+ }
+
+ /// Processes incoming sensor data and updates jump height.
+ void _processSensorData(Map data) {
+ /// Kalman filtered accelerometer data for X.
+ _accX = _kalmanX.filtered(data["ACC"]["X"]);
+ /// Kalman filtered accelerometer data for Y.
+ _accY = _kalmanY.filtered(data["ACC"]["Y"]);
+ /// Kalman filtered accelerometer data for Z.
+ _accZ = _kalmanZ.filtered(data["ACC"]["Z"]);
+ /// Pitch angle in radians.
+ _pitch = data["EULER"]["PITCH"];
+ // Calculates the current vertical acceleration.
+ // It adjusts the Z-axis acceleration with the pitch angle to account for the device's orientation.
+ double currentAcc = _accZ * cos(_pitch) + _accX * sin(_pitch);
+ // Subtract gravity to get acceleration due to movement.
+ currentAcc -= _gravity;
+
+ _updateHeight(currentAcc);
+ }
+
+ /// Checks if the device is stationary based on acceleration magnitude.
+ bool _deviceIsStationary(double threshold) {
+ double accMagnitude = sqrt(_accX * _accX + _accY * _accY + _accZ * _accZ);
+ bool isStationary = (accMagnitude > _gravity - threshold) && (accMagnitude < _gravity + threshold);
+ return isStationary;
+ }
+
+ /// Updates the current height based on the current acceleration.
+ /// If the device is stationary, the velocity is reset to 0.
+ /// Otherwise, it integrates the current acceleration to update velocity and height.
+ _updateHeight(double currentAcc) {
+ setState(() {
+ if (_deviceIsStationary(0.3)) {
+ _velocity = 0.0;
+ _height = 0.0;
+ } else {
+ // Integrate acceleration to get velocity.
+ _velocity += currentAcc * _timeSlice;
+
+ // Integrate velocity to get height.
+ _height += _velocity * _timeSlice;
+ }
+
+ // Prevent height from going negative.
+ _height = max(0, _height);
+
+ // Update maximum height if the current height is greater.
+ if (_height > _maxHeight) {
+ _maxHeight = _height;
+ }
+
+ _jumpData.add(Jump(DateTime.now(), _height));
+ });
+ // For debugging.
+ // print("Stationary: ${deviceIsStationary(0.3)}, Acc: $currentAcc, Vel: $velocity, Height: $height");
+ }
+
+ String _prettyDuration(Duration duration) {
+ var seconds = duration.inMilliseconds / 1000;
+ return '${seconds.toStringAsFixed(2)} s';
+ }
+
+ /// Builds the UI for the jump height test.
+ /// It displays a line chart of jump height over time and the maximum jump height achieved.
+ // This build function is getting a little too big. Consider refactoring.
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: Theme.of(context).colorScheme.background,
+ appBar: AppBar(
+ title: Text('Jump Height Test'),
+ ),
+ body: Column(
+ children: [
+ TabBar(
+ controller: _tabController,
+ indicatorColor: Colors.white, // Color of the underline indicator
+ labelColor: Colors.white, // Color of the active tab label
+ unselectedLabelColor: Colors.grey, // Color of the inactive tab labels
+ tabs: [
+ Tab(text: 'Height'),
+ Tab(text: 'Raw Acc.'),
+ Tab(text: 'Filtered Acc.'),
+ ],
+ ),
+ Expanded(
+ child: (!_openEarable.bleManager.connected)
+ ? _notConnectedWidget()
+ : _buildJumpHeightDataTabs(),
+ ),
+ SizedBox(height: 20), // Margin between chart and button
+ _buildButtons(),
+ Visibility(
+ // Show error message if no OpenEarable device is connected.
+ visible: !_earableConnected,
+ maintainState: true,
+ maintainAnimation: true,
+ child: Text(
+ "No Earable Connected",
+ style: TextStyle(
+ color: Colors.red,
+ fontSize: 12,
+ ),
+ ),
+ ),
+ SizedBox(height: 20), // Margin between button and text
+ _buildText()
+ ],
+ ),
+ );
+ }
+
+ Widget _notConnectedWidget() {
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.warning,
+ size: 48,
+ color: Colors.red,
+ ),
+ SizedBox(height: 16),
+ Center(
+ child: Text(
+ "Not connected to\nOpenEarable device",
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildJumpHeightDataTabs() {
+ return TabBarView(
+ controller: _tabController,
+ children: [
+ JumpHeightChart(_openEarable, "Height Data"),
+ JumpHeightChart(_openEarable, "Raw Acceleration Data"),
+ JumpHeightChart(_openEarable, "Filtered Acceleration Data")
+ ],
+ );
+ }
+
+ Widget _buildText() {
+ return Container(
+ child: Column(
+ children: [
+ Text(
+ 'Max height: ${_maxHeight.toStringAsFixed(2)} m',
+ style: Theme.of(context).textTheme.headlineMedium,
+ ),
+ Text('Jump time: ${_prettyDuration(_jumpDuration)}',
+ style: Theme.of(context).textTheme.headlineSmall,
+ ),
+ ],
+ ),
+ );
+ }
+
+
+ /// Builds buttons to start and stop the jump height measurement process.
+ Widget _buildButtons() {
+ return Flexible(
+ child: ElevatedButton(
+ onPressed: _earableConnected
+ ? () {
+ _isJumping ? _stopJump() : _startJump();
+ }
+ : null,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: !_isJumping ? Colors.greenAccent : Colors.red,
+ foregroundColor: Colors.black,
+ ),
+ child: Text(_isJumping ? 'Stop Jump' : 'Set Baseline & Start Jump'),
+ ),
+ );
+ }
+
+ /// Builds a sensor configuration for the OpenEarable device.
+ /// Sets the sensor ID, sampling rate, and latency.
+ OpenEarableSensorConfig _buildSensorConfig() {
+ return OpenEarableSensorConfig(
+ sensorId: 0,
+ samplingRate: 30,
+ latency: 0,
+ );
+ }
+}
diff --git a/open_earable/lib/apps_tab.dart b/open_earable/lib/apps_tab.dart
index 17e5a27..85dec37 100644
--- a/open_earable/lib/apps_tab.dart
+++ b/open_earable/lib/apps_tab.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:open_earable/apps/posture_tracker/model/earable_attitude_tracker.dart';
import 'package:open_earable/apps/posture_tracker/view/posture_tracker_view.dart';
import 'package:open_earable/apps/recorder.dart';
+import 'package:open_earable/apps/jump_height_test/jump_height_test.dart';
import 'package:open_earable_flutter/src/open_earable_flutter.dart';
class AppInfo {
@@ -45,6 +46,16 @@ class AppsTab extends StatelessWidget {
MaterialPageRoute(
builder: (context) => Recorder(_openEarable)));
}),
+ AppInfo(
+ iconData: Icons.height,
+ title: "Jump Height Test",
+ description: "Test your maximum jump height.",
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => JumpHeightTest(_openEarable)));
+ }),
// ... similarly for other apps
];
}