diff --git a/.gitignore b/.gitignore
index 9e6aaf3..a2ed22c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,3 +117,6 @@ Temporary Items
.history
.ionide
.vscode/settings.json
+
+# idea folder
+.idea
diff --git a/README.md b/README.md
index a614ab2..7a28918 100644
--- a/README.md
+++ b/README.md
@@ -21,12 +21,14 @@
## Table of Contents
-- [Introduction](#Introduction)
-- [Features](#Features)
-- [Getting Started](#getting-started)
+- [OpenEarable - App v1.3.0](#openearable---app-v130)
+ - [Table of Contents](#table-of-contents)
+ - [Introduction](#introduction)
+ - [Features](#features)
+ - [Getting Started](#getting-started)
- [Run the app](#run-the-app)
- [Install the app](#install-the-app)
-- [Contribute your own example app](#contribute-your-own-example-app)
+ - [Contribute your own example app](#contribute-your-own-example-app)
## Introduction
@@ -80,7 +82,8 @@ To get started with the OpenEarable App, you need to have the following:
## Contribute your own example app
If you want to contribute your own example app, please follow the steps below:
-1. Create a new folder in the `lib/apps` folder for your app
+1. Create a new folder in the `lib/apps_tab` folder for your app
2. Develop your app in the new folder
-3. Add an instance of the `AppInfo` class to `sampleApps` property in the `lib/apps/apps_tab.dart` file to include your app in the list of example apps
-4. Create a pull request to this repository
+3. Add an instance of the `AppInfo` class to `sampleApps` property in the `lib/apps_tab/apps_tab.dart` file to include your app in the list of example apps
+4. If your app contains any assets, add the path to your assets to the `pubspec.yaml` file
+5. Create a pull request to this repository
diff --git a/StretchApp.md b/StretchApp.md
new file mode 100644
index 0000000..62fca30
--- /dev/null
+++ b/StretchApp.md
@@ -0,0 +1,53 @@
+# Guided Neck Stretch App
+
+## Goal
+This App is used to allow users to easily start a stretching exercise for their neck without a lot of trouble. To ensure that the user doesn't have to look at the screen while enjoying their stretching session, it also has a stats tab which displays valuable information to be viewed after they just stretched. Furthermore the app signals to the user whenever the next stretching session starts and when a break is occuring, so that the user can stay calm and close his eyes without focusing on his own set time constraints.
+To ensure a more intimate experience the user can also set his own threshold goals and stretching duration for each of the stretch exercises, and modify the break times in between. If a user is new or unsure how to use this app or to execute the stretch exercises in the right manner, a guide tab is provided to help the user understand the app and it's UI and to show them a video regarding the used stretch exercises.
+
+## Assets
+The assets are modified images from the posture tracker app to ensure consistency within the OpenEarable app. These images always display the stretched area with a blue indicator color.
+- `Neck_Stretch_Left.png`: Image displaying the neck with indicators of a "left stretch".
+- `Neck_Main_Stretch.png`: Image displaying the neck with indicators of a "main stretch".
+- `Neck_Right_Stretch.png`: Image displaying the neck with indicators of a "right stretch".
+- `Neck_Side_Stretch.png`: Image displaying the neck with indicators of a stretch of both sides of the neck.
+
+## Model
+### Stretch Colors
+This file stores all colors used within the stretch app to assure easy exchangeability and consistency within this app.
+
+### Stretch State
+This file stores all classes used to store stretching information, such as
+- `NeckStretchState`: Stores all data concerning a stretching state and it's asset paths
+- `StretchStats`: Stores all data concering the most recent stretching session
+- `StretchSettings`: Stores all data needed to configure a stretching session
+- `NeckStretch`: Provides a one class solution which compromises all of the Model Data into this Class. It provides all functions needed to get and modify the data and also has all the code concerning the stretch state switches.
+
+## View
+
+### App View
+This file consiststs of the module used to display all the submodules of this app and is built up just like the normal app selector in the open-earable app itself. Notable is that this is a stateful widget which initializes the final `StretchViewModel` object which is used to store and manage all data needed for this app.
+
+### Stretch Arc Painter
+This is a modified version of the `arc_painter` used in the `posture_tracker` app, which draws the right indicators with the right colors from the `stretch_colors.dart` to indicate whether the user is currently stretching in the right direction and to display what area is desireable or undesireable for the current stretch.
+
+### Stretch Roll View
+This is a modified version of the `roll_view` used in the `posture_tracker` app, which is used to draw the whole "head area" of a tracking session. This file is edited to support the different neck stretch types and draw the arcs according to them.
+
+### Stretch Settings View
+This is the view used to display the settings module and edit all the settings for a neck stretching session. It uses the `TextEditingController` to parse any input by the user, which is then used to set the right settings in the `StretchViewModel` for a stretching exercise.
+
+### Stretch Stats View
+This is the view used to display the stats of the most recent stretching exercise. These stats are stored and editied by the `StretchViewModel`.
+
+### Stretch Tracker View
+This module is the view of the stretch tracker module. Here you can start stretching and can track your stretching progess via the UI. The UI is drawn using an modified version of the `posture arc_painter.dart`, the `stretch_arc_painter.dart`. This view also provides certain functions to easily draw the head tracker view in other modules.
+
+### Stretch Tutorial View
+This is the view for the stretch tutorial module, which is used to show the user how to use this app, and has an embedded youtube video (using an [youtube player package](https://pub.dev/packages/youtube_player_flutter)), which shows all of the neck stretches used by this app.
+
+## View Model
+### Stretch View Model
+This file stores the `StretchViewModel`, which stores all data used by this app and is used to change it on the fly by the submodules. It also provides the functionality to track the stats of the user for the Stretch Stats View. Furthermore it is used to stop and start the tracking by the earable.
+
+---
+By Soheel Dario Aghadavoodi Jolfaei - [GitHub](https://github.com/BasicallyPolaris/oe-app)
\ No newline at end of file
diff --git a/open_earable/devtools_options.yaml b/open_earable/devtools_options.yaml
new file mode 100644
index 0000000..7e7e7f6
--- /dev/null
+++ b/open_earable/devtools_options.yaml
@@ -0,0 +1 @@
+extensions:
diff --git a/open_earable/ios/Podfile.lock b/open_earable/ios/Podfile.lock
index 9db7ab7..740b65c 100644
--- a/open_earable/ios/Podfile.lock
+++ b/open_earable/ios/Podfile.lock
@@ -5,20 +5,31 @@ PODS:
- flutter_gl (0.0.3):
- Flutter
- three3d_egl (~> 0.1.3)
+ - flutter_inappwebview (0.0.1):
+ - Flutter
+ - flutter_inappwebview/Core (= 0.0.1)
+ - OrderedSet (~> 5.0)
+ - flutter_inappwebview/Core (0.0.1):
+ - Flutter
+ - OrderedSet (~> 5.0)
- flutter_native_splash (0.0.1):
- Flutter
- open_file (0.0.1):
- Flutter
+ - OrderedSet (5.0.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- - permission_handler_apple (9.1.1):
+ - permission_handler_apple (9.3.0):
- Flutter
- Protobuf (3.24.3)
- reactive_ble_mobile (0.0.1):
- Flutter
- Protobuf (~> 3.5)
- SwiftProtobuf (~> 1.0)
+ - shared_preferences_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
- SwiftProtobuf (1.23.0)
- three3d_egl (0.1.3)
@@ -26,14 +37,17 @@ DEPENDENCIES:
- app_settings (from `.symlinks/plugins/app_settings/ios`)
- Flutter (from `Flutter`)
- flutter_gl (from `.symlinks/plugins/flutter_gl/ios`)
+ - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- open_file (from `.symlinks/plugins/open_file/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- reactive_ble_mobile (from `.symlinks/plugins/reactive_ble_mobile/ios`)
+ - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
SPEC REPOS:
trunk:
+ - OrderedSet
- Protobuf
- SwiftProtobuf
- three3d_egl
@@ -45,6 +59,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_gl:
:path: ".symlinks/plugins/flutter_gl/ios"
+ flutter_inappwebview:
+ :path: ".symlinks/plugins/flutter_inappwebview/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
open_file:
@@ -55,17 +71,22 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
reactive_ble_mobile:
:path: ".symlinks/plugins/reactive_ble_mobile/ios"
+ shared_preferences_foundation:
+ :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
SPEC CHECKSUMS:
app_settings: 017320c6a680cdc94c799949d95b84cb69389ebc
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_gl: 5a5603f35db897697f064027864a32b15d0c421d
+ flutter_inappwebview: 3d32228f1304635e7c028b0d4252937730bbc6cf
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
open_file: 02eb5cb6b21264bd3a696876f5afbfb7ca4f4b7d
- path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
- permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
+ OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
+ path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
+ permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78
Protobuf: 970f7ee93a3a08e3cf64859b8efd95ee32b4f87f
reactive_ble_mobile: 9ce6723d37ccf701dbffd202d487f23f5de03b4c
+ shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
SwiftProtobuf: b70d65f419fbfe61a2d58003456ca5da58e337d6
three3d_egl: de2cd4950ad2d5f2122166c36583bde4c812e7b5
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/ios/ci_scripts/ci_post_clone.sh b/open_earable/ios/ci_scripts/ci_post_clone.sh
index f888c39..ed9a153 100755
--- a/open_earable/ios/ci_scripts/ci_post_clone.sh
+++ b/open_earable/ios/ci_scripts/ci_post_clone.sh
@@ -5,8 +5,8 @@ set -e
# by default, the execution directory of this script is the ci_scripts directory
# CI_WORKSPACE is the directory of your cloned repo
-echo "🟩 Navigate from ($PWD) to ($CI_WORKSPACE)"
-cd $CI_WORKSPACE
+echo "🟩 Navigate from ($PWD) to ($CI_WORKSPACE_PATH)"
+cd $CI_WORKSPACE_PATH
echo "🟩 Install Flutter"
time git clone https://github.com/flutter/flutter.git -b stable $HOME/flutter
@@ -16,7 +16,7 @@ echo "🟩 Flutter Precache"
time flutter precache --ios
echo "🟩 Install Flutter Dependencies"
-cd open_earable
+cd repository/open_earable
time flutter clean
time flutter pub get
time flutter pub upgrade
diff --git a/open_earable/lib/apps/recorder.dart b/open_earable/lib/apps/recorder.dart
deleted file mode 100644
index 8fdfbf6..0000000
--- a/open_earable/lib/apps/recorder.dart
+++ /dev/null
@@ -1,423 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:open_earable/widgets/earable_not_connected_warning.dart';
-import 'dart:async';
-import 'dart:io';
-import 'package:open_earable_flutter/src/open_earable_flutter.dart';
-import 'package:path_provider/path_provider.dart';
-import 'package:open_file/open_file.dart';
-
-class Recorder extends StatefulWidget {
- final OpenEarable _openEarable;
- Recorder(this._openEarable);
- @override
- _RecorderState createState() => _RecorderState(_openEarable);
-}
-
-class _RecorderState extends State {
- List _recordings = [];
- final OpenEarable _openEarable;
- bool _recording = false;
- StreamSubscription? _imuSubscription;
- StreamSubscription? _barometerSubscription;
- _RecorderState(this._openEarable);
- CsvWriter? _csvWriter;
- late List _csvHeader;
- late List _labels;
- late String _selectedLabel;
- Timer? _timer;
- Duration _duration = Duration();
- @override
- void initState() {
- super.initState();
-
- _labels = [
- "No Label",
- "Label 1",
- "Label 2",
- "Label 3",
- "Label 4",
- "Label 5",
- "Label 6",
- "Label 7",
- "Label 8",
- ];
- _selectedLabel = "No Label";
- _csvHeader = [
- "time",
- "sensor_accX[m/s]",
- "sensor_accY[m/s]",
- "sensor_accZ[m/s]",
- "sensor_gyroX[°/s]",
- "sensor_gyroY[°/s]",
- "sensor_gyroZ[°/s]",
- "sensor_magX[µT]",
- "sensor_magY[µT]",
- "sensor_magZ[µT]",
- "sensor_yaw[°]",
- "sensor_pitch[°]",
- "sensor_roll[°]",
- "sensor_baro[Pa]",
- "sensor_temp[°C]",
- ];
- _csvHeader.addAll(
- _labels.sublist(1).map((label) => "label_OpenEarable_${label}"));
- if (_openEarable.bleManager.connected) {
- _setupListeners();
- }
- listFilesInDocumentsDirectory();
- }
-
- void _startTimer() {
- _timer = Timer.periodic(Duration(seconds: 1), (timer) {
- setState(() {
- _duration = _duration + Duration(seconds: 1);
- });
- });
- }
-
- void _stopTimer() {
- if (_timer != null) {
- _timer!.cancel();
- _duration = Duration();
- }
- }
-
- String _formatDuration(Duration duration) {
- String twoDigits(int n) => n.toString().padLeft(2, '0');
- String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
- String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
- return "$twoDigitMinutes:$twoDigitSeconds";
- }
-
- @override
- void dispose() {
- super.dispose();
- _timer?.cancel();
- _imuSubscription?.cancel();
- _barometerSubscription?.cancel();
- }
-
- Future listFilesInDocumentsDirectory() async {
- Directory documentsDirectory = await getApplicationDocumentsDirectory();
- List files = documentsDirectory.listSync();
- _recordings.clear();
- for (var file in files) {
- if (file is File) {
- _recordings.add(file);
- }
- }
- _recordings.sort((a, b) {
- return b.statSync().changed.compareTo(a.statSync().changed);
- });
-
- setState(() {});
- }
-
- _setupListeners() {
- _imuSubscription =
- _openEarable.sensorManager.subscribeToSensorData(0).listen((data) {
- if (!_recording) {
- return;
- }
- String timestamp = data["timestamp"].toString();
-
- String ax = data["ACC"]["X"].toString();
- String ay = data["ACC"]["Y"].toString();
- String az = data["ACC"]["Z"].toString();
-
- String gx = data["GYRO"]["X"].toString();
- String gy = data["GYRO"]["Y"].toString();
- String gz = data["GYRO"]["Z"].toString();
-
- String mx = data["MAG"]["X"].toString();
- String my = data["MAG"]["Y"].toString();
- String mz = data["MAG"]["Z"].toString();
-
- String eulerYaw = data["EULER"]["YAW"].toString();
- String eulerPitch = data["EULER"]["PITCH"].toString();
- String eulerRoll = data["EULER"]["ROLL"].toString();
-
- List imuRow = [
- timestamp,
- ax,
- ay,
- az,
- gx,
- gy,
- gz,
- mx,
- my,
- mz,
- eulerYaw,
- eulerPitch,
- eulerRoll,
- "",
- "",
- ];
- imuRow.addAll(_getLabels());
- _csvWriter?.addData(imuRow);
- });
-
- _barometerSubscription =
- _openEarable.sensorManager.subscribeToSensorData(1).listen((data) {
- if (!_recording) {
- return;
- }
- String timestamp = data["timestamp"].toString();
- String pressure = data["BARO"]["Pressure"].toString();
- String temperature = data["TEMP"]["Temperature"].toString();
-
- List barometerRow = [
- timestamp,
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- "",
- pressure,
- temperature,
- ];
- barometerRow.addAll(_getLabels());
- _csvWriter?.addData(barometerRow);
- });
- }
-
- List _getLabels() {
- List markedLabels = List.filled(_labels.length - 1, "");
- int selectedLabelIndex = _labels.indexOf(_selectedLabel);
- if (_selectedLabel == "No Label" || selectedLabelIndex == -1) {
- return markedLabels;
- }
- markedLabels[selectedLabelIndex - 1] = "x";
- return markedLabels;
- }
-
- void startStopRecording() async {
- if (_recording) {
- setState(() {
- _recording = false;
- });
- _csvWriter?.cancelTimer();
- _stopTimer();
- } else {
- _csvWriter = CsvWriter(listFilesInDocumentsDirectory);
- _csvWriter?.addData(_csvHeader);
- _startTimer();
- setState(() {
- _recording = true;
- });
- }
- }
-
- void deleteFile(File file) {
- if (file.existsSync()) {
- try {
- file.deleteSync();
- } catch (e) {
- print('Error deleting file: $e');
- }
- } else {
- print('File does not exist.');
- }
- listFilesInDocumentsDirectory();
- }
-
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- backgroundColor: Theme.of(context).colorScheme.background,
- appBar: AppBar(
- title: Text('Recorder'),
- ),
- body: _openEarable.bleManager.connected
- ? _recorderWidget()
- : EarableNotConnectedWarning());
- }
-
- Widget _recorderWidget() {
- return Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Padding(
- padding: EdgeInsets.fromLTRB(16, 16, 16, 0),
- child: Text(
- _formatDuration(_duration),
- style: TextStyle(
- fontFamily: 'Digital', // This is a common monospaced font
- fontSize: 80,
- fontWeight: FontWeight.normal,
- ),
- ),
- ),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Padding(
- padding: EdgeInsets.all(16),
- child: ElevatedButton(
- onPressed: startStopRecording,
- style: ElevatedButton.styleFrom(
- minimumSize: Size(200, 36),
- backgroundColor: _recording
- ? Color(0xfff27777)
- : Theme.of(context).colorScheme.secondary,
- foregroundColor: Colors.black,
- ),
- child: Text(
- _recording ? "Stop Recording" : "Start Recording",
- style: TextStyle(fontSize: 20),
- ),
- ),
- ),
- DropdownButton(
- value: _selectedLabel,
- icon: const Icon(Icons.arrow_drop_down),
- onChanged: (String? newValue) {
- setState(() {
- _selectedLabel = newValue!;
- });
- },
- items: _labels.map>((String value) {
- return DropdownMenuItem(
- value: value,
- child: Text(value),
- );
- }).toList(),
- ),
- ],
- ),
- Text("Recordings", style: TextStyle(fontSize: 20.0)),
- Divider(
- thickness: 2,
- ),
- Expanded(
- child: _recordings.isEmpty
- ? 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(
- "No recordings found",
- style: TextStyle(
- fontSize: 20,
- fontWeight: FontWeight.bold,
- ),
- textAlign: TextAlign.center,
- ),
- ),
- ],
- ),
- ),
- ],
- )
- : ListView.builder(
- itemCount: _recordings.length,
- itemBuilder: (context, index) {
- return ListTile(
- title: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Expanded(
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- _recordings[index].path.split("/").last,
- maxLines: 1,
- ),
- ),
- ),
- IconButton(
- icon: Icon(Icons.delete,
- color: (_recording && index == 0)
- ? Color.fromARGB(50, 255, 255, 255)
- : Colors.white),
- onPressed: () {
- (_recording && index == 0)
- ? null
- : deleteFile(_recordings[index]);
- },
- splashColor: (_recording && index == 0)
- ? Colors.transparent
- : Theme.of(context).splashColor,
- ),
- ],
- ),
- onTap: () {
- OpenFile.open(_recordings[index].path);
- },
- );
- },
- ),
- )
- ],
- ),
- );
- }
-}
-
-class CsvWriter {
- List> buffer = [];
- File? file;
- late Timer _timer;
-
- CsvWriter(void Function() fileCreationClosure) {
- if (file == null) {
- _openFile(fileCreationClosure);
- }
- _timer = Timer.periodic(Duration(milliseconds: 250), (Timer timer) {
- if (buffer.isNotEmpty) {
- if (file == null) {
- _openFile(fileCreationClosure);
- }
- writeBufferToFile();
- }
- });
- }
-
- cancelTimer() {
- _timer.cancel();
- }
-
- void addData(List data) {
- buffer.add(data);
- }
-
- Future _openFile(void Function() fileCreationClosure) async {
- DateTime startTime = DateTime.now();
- String formattedDate =
- startTime.toUtc().toIso8601String().replaceAll(':', '_');
- formattedDate = "${formattedDate.substring(0, formattedDate.length - 4)}Z";
- String fileName = 'recording_$formattedDate';
- String directory = (await getApplicationDocumentsDirectory()).path;
- String filePath = '$directory/$fileName.csv';
- file = File(filePath);
- await file?.create();
- fileCreationClosure();
- }
-
- void writeBufferToFile() async {
- if (file != null) {
- String csvData = buffer.map((row) => row.join(',')).join('\n');
- file!.writeAsStringSync('$csvData\n', mode: FileMode.append);
- buffer.clear();
- }
- }
-}
diff --git a/open_earable/lib/apps/step_counter.dart b/open_earable/lib/apps/step_counter.dart
deleted file mode 100644
index e69de29..0000000
diff --git a/open_earable/lib/apps_tab.dart b/open_earable/lib/apps_tab.dart
deleted file mode 100644
index 10a8f66..0000000
--- a/open_earable/lib/apps_tab.dart
+++ /dev/null
@@ -1,90 +0,0 @@
-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_flutter/src/open_earable_flutter.dart';
-
-import 'package:open_earable/apps/stepCounter/step_counter.dart';
-
-class AppInfo {
- final IconData iconData;
- final String title;
- final String description;
- final VoidCallback onTap;
-
- AppInfo(
- {required this.iconData,
- required this.title,
- required this.description,
- required this.onTap});
-}
-
-class AppsTab extends StatelessWidget {
- final OpenEarable _openEarable;
-
- AppsTab(this._openEarable);
-
- List sampleApps(BuildContext context) {
- return [
- AppInfo(
- iconData: Icons.face_6,
- title: "Posture Tracker",
- description: "Get feedback on bad posture.",
- onTap: () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => PostureTrackerView(
- EarableAttitudeTracker(_openEarable), _openEarable)));
- }),
- AppInfo(
- iconData: Icons.fiber_smart_record,
- title: "Recorder",
- description: "Record data from OpenEarable.",
- onTap: () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => Recorder(_openEarable)));
- }),
- // ... similarly for other apps
- AppInfo(
- iconData: Icons.do_not_step,
- title: "StepCounter",
- description: "StepCounter and Average Step per Second",
- onTap: () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => StepCounter(_openEarable)));
- }),
- ];
- }
-
- @override
- Widget build(BuildContext context) {
- List apps = sampleApps(context);
-
- return Padding(
- padding: const EdgeInsets.only(top: 5),
- child: ListView.builder(
- itemCount: apps.length,
- itemBuilder: (BuildContext context, int index) {
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 5),
- child: Card(
- color: Theme.of(context).colorScheme.primary,
- child: ListTile(
- leading: Icon(apps[index].iconData, size: 40.0),
- title: Text(apps[index].title),
- subtitle: Text(apps[index].description),
- trailing: Icon(Icons.arrow_forward_ios,
- size: 16.0), // Arrow icon on the right
- onTap:
- apps[index].onTap, // Callback when the card is tapped
- ),
- ));
- },
- ));
- }
-}
diff --git a/open_earable/lib/apps_tab/apps_tab.dart b/open_earable/lib/apps_tab/apps_tab.dart
new file mode 100644
index 0000000..a392e73
--- /dev/null
+++ b/open_earable/lib/apps_tab/apps_tab.dart
@@ -0,0 +1,191 @@
+import 'dart:io';
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:open_earable/apps_tab/posture_tracker/model/earable_attitude_tracker.dart';
+import 'package:open_earable/apps_tab/posture_tracker/view/posture_tracker_view.dart';
+import 'package:open_earable/apps_tab/neck_stretch/view/stretch_app_view.dart';
+import 'package:open_earable/apps_tab/step_counter/step_counter.dart';
+import 'package:open_earable/apps_tab/tightness/tightness.dart';
+import 'package:open_earable/apps_tab/recorder/lib/recorder.dart';
+import 'package:open_earable/apps_tab/jump_height_test/jump_height_test.dart';
+import 'package:open_earable_flutter/src/open_earable_flutter.dart';
+import '../shared/global_theme.dart';
+import 'package:open_earable/apps_tab/jump_rope_counter/jump_rope_counter.dart';
+import 'powernapper/home_screen.dart';
+
+class AppInfo {
+ final String logoPath;
+ final String title;
+ final String description;
+ final VoidCallback onTap;
+
+ AppInfo(
+ {required this.logoPath,
+ required this.title,
+ required this.description,
+ required this.onTap});
+}
+
+class AppsTab extends StatelessWidget {
+ final OpenEarable _openEarable;
+
+ AppsTab(this._openEarable);
+
+ List sampleApps(BuildContext context) {
+ return [
+ AppInfo(
+ logoPath: "lib/apps_tab/recorder/assets/logo.png",
+ title: "Recorder",
+ description: "Record data from OpenEarable",
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => Material(
+ child: Theme(
+ data: materialTheme,
+ child: Recorder(_openEarable)))));
+ }),
+ AppInfo(
+ logoPath:
+ "lib/apps_tab/posture_tracker/assets/logo.png", //iconData: Icons.face_6,
+ title: "Posture Tracker",
+ description: "Get feedback on bad posture",
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => Material(
+ child: Theme(
+ data: materialTheme,
+ child: PostureTrackerView(
+ EarableAttitudeTracker(_openEarable),
+ _openEarable)))));
+ }),
+ AppInfo(
+ logoPath:
+ "lib/apps_tab/jump_height_test/assets/logo.png", //Icons.height,
+ title: "Jump Height Test",
+ description: "Measure the height of your jumps",
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => Material(
+ child: Theme(
+ data: materialTheme,
+ child: Material(
+ child: JumpHeightTest(_openEarable))))));
+ }),
+ AppInfo(
+ logoPath:
+ "lib/apps_tab/jump_rope_counter/assets/logo.png", //iconData: Icons.keyboard_double_arrow_up,
+ title: "Jump Rope Counter",
+ description: "Count your rope skips",
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => Material(
+ child: Theme(
+ data: materialTheme,
+ child: JumpRopeCounter(_openEarable)))));
+ }),
+ AppInfo(
+ logoPath: "lib/apps_tab/step_counter/assets/logo.png",
+ title: "Step Counter",
+ description: "Count your Steps",
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => Material(
+ child: Theme(
+ data: materialTheme,
+ child: StepCounter(_openEarable)))));
+ }),
+ AppInfo(
+ logoPath:
+ "lib/apps_tab/powernapper/assets/logo.png", //iconData: Icons.face_5,
+ title: "Powernapper",
+ description: "Time starts when you're asleep",
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => Material(
+ child: Theme(
+ data: materialTheme,
+ child: SleepHomeScreen(_openEarable)))));
+ }),
+ AppInfo(
+ logoPath:
+ "lib/apps_tab/tightness/assets/logo.png", //iconData: Icons.music_note,
+ title: "Tightness Meter",
+ description: "Practice your sense of rythm",
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => Material(
+ child: Theme(
+ data: materialTheme,
+ child: TightnessMeter(_openEarable)))));
+ }),
+ AppInfo(
+ logoPath: "lib/apps_tab/neck_stretch/assets/logo.png",
+ title: "Guided Neck Stretch",
+ description: "Relax your neck with a stretch.",
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => Material(
+ child: Theme(
+ data: materialTheme,
+ child: StretchAppView(
+ EarableAttitudeTracker(_openEarable),
+ _openEarable)))));
+ }),
+ // ... similarly for other apps
+ ];
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ List apps = sampleApps(context);
+
+ return Padding(
+ padding: const EdgeInsets.only(top: 5),
+ child: ListView.builder(
+ itemCount: apps.length,
+ itemBuilder: (BuildContext context, int index) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 5),
+ child: Card(
+ color: Platform.isIOS
+ ? CupertinoTheme.of(context).primaryContrastingColor
+ : Theme.of(context).colorScheme.primary,
+ child: ListTile(
+ iconColor: Colors.white,
+ textColor: Colors.white,
+ leading: SizedBox(
+ height: 50.0,
+ width: 50.0,
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(8.0),
+ child: Image.asset(apps[index].logoPath,
+ fit: BoxFit.cover))),
+ title: Text(apps[index].title),
+ subtitle: Text(apps[index].description),
+ trailing: Icon(Icons.arrow_forward_ios,
+ size: 16.0), // Arrow icon on the right
+ onTap:
+ apps[index].onTap, // Callback when the card is tapped
+ ),
+ ));
+ },
+ ));
+ }
+}
diff --git a/open_earable/lib/apps_tab/jump_height_test/assets/logo.png b/open_earable/lib/apps_tab/jump_height_test/assets/logo.png
new file mode 100644
index 0000000..ac70a5b
Binary files /dev/null and b/open_earable/lib/apps_tab/jump_height_test/assets/logo.png differ
diff --git a/open_earable/lib/apps_tab/jump_height_test/jump_height_chart.dart b/open_earable/lib/apps_tab/jump_height_test/jump_height_chart.dart
new file mode 100644
index 0000000..41b1652
--- /dev/null
+++ b/open_earable/lib/apps_tab/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_tab/jump_height_test/jump_height_test.dart b/open_earable/lib/apps_tab/jump_height_test/jump_height_test.dart
new file mode 100644
index 0000000..4ae9a02
--- /dev/null
+++ b/open_earable/lib/apps_tab/jump_height_test/jump_height_test.dart
@@ -0,0 +1,331 @@
+import 'package:flutter/material.dart';
+import 'package:open_earable/apps_tab/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';
+import 'package:open_earable/shared/earable_not_connected_warning.dart';
+
+/// 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)
+ ? EarableNotConnectedWarning()
+ : _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 _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/jump_rope_counter/assets/logo.png b/open_earable/lib/apps_tab/jump_rope_counter/assets/logo.png
new file mode 100644
index 0000000..470f3e7
Binary files /dev/null and b/open_earable/lib/apps_tab/jump_rope_counter/assets/logo.png differ
diff --git a/open_earable/lib/apps_tab/jump_rope_counter/jump_rope_counter.dart b/open_earable/lib/apps_tab/jump_rope_counter/jump_rope_counter.dart
new file mode 100644
index 0000000..399aeb1
--- /dev/null
+++ b/open_earable/lib/apps_tab/jump_rope_counter/jump_rope_counter.dart
@@ -0,0 +1,512 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:open_earable/shared/earable_not_connected_warning.dart';
+import 'package:open_earable_flutter/src/open_earable_flutter.dart';
+import 'dart:async';
+import 'package:intl/intl.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// Result of a jump rope session.
+/// Contains the number of jumps, duration of the session and average jump height.
+class JumpRecordResult {
+ final String date;
+ final int jumps;
+ final Duration duration;
+
+ /// Constructor for JumpRecordResult.
+ JumpRecordResult({required this.jumps, required this.duration})
+ : date = DateFormat('dd. MMMM yyyy HH:mm').format(DateTime.now());
+}
+
+/// JumpRopeCounter widget.
+class JumpRopeCounter extends StatefulWidget {
+ /// Instance of OpenEarable device.
+ final OpenEarable _openEarable;
+
+ /// Constructor for JumpRopeCounter widget.
+ JumpRopeCounter(this._openEarable);
+
+ _JumpRopeCounterState createState() => _JumpRopeCounterState(_openEarable);
+}
+
+/// The state of the JumpRopeCounter widget.
+/// Contains the UI and logic for the JumpRopeCounter widget.
+class _JumpRopeCounterState extends State
+ with SingleTickerProviderStateMixin {
+ /// Instance of OpenEarable device.
+ final OpenEarable _openEarable;
+
+ /// Subscription to the IMU sensor.
+ StreamSubscription? _imuSubscription;
+
+ /// Jump detection.
+ bool _detectedJump = false;
+
+ bool _firstJump = true;
+
+ /// Sampling rate for the accelerometer.
+ double _samplingRate = 10.0;
+
+ /// Number of jumps.
+ int _jumps = 0;
+
+ /// Average jump height.
+ double _avgJumpHeight = 0.0;
+
+ /// Gravitational acceleration.
+ final double _gravity = 9.81;
+
+ /// Timer for recording duration.
+ Timer? _timer;
+
+ /// Recording state.
+ bool _recording = false;
+
+ /// Duration of the recording.
+ Duration _duration = Duration();
+
+ /// Maximum number of saved recordings.
+ int _maxSavedItems = 50;
+
+ /// List of past recordings.
+ late List _recordings = [];
+
+ /// Tab controller for the two tabs.
+ late TabController _tabController;
+
+ /// Amount of tabs.
+ late int _tabAmount = 2;
+
+ /// Constructor for _JumpRopeCounterState.
+ _JumpRopeCounterState(this._openEarable);
+
+ /// Initializes state and sets up listeners for sensor data.
+ @override
+ void initState() {
+ super.initState();
+ loadJumpRecordings();
+ _tabController =
+ TabController(length: _tabAmount, vsync: this, initialIndex: 0);
+ if (_openEarable.bleManager.connected) {
+ /// Set sampling rate to maximum.
+ _openEarable.sensorManager.writeSensorConfig(_buildSensorConfig());
+
+ /// Setup listeners for sensor data.
+ _setupListeners();
+ }
+ }
+
+ /// Cancels the subscription to the IMU sensor when the widget is disposed.
+ @override
+ void dispose() {
+ super.dispose();
+ _imuSubscription?.cancel();
+ }
+
+ // Loads the past recordings from shared preferences.
+ void loadJumpRecordings() async {
+ SharedPreferences prefs = await SharedPreferences.getInstance();
+ List? recordings = prefs.getStringList('jumpRecordings');
+ setState(() {
+ _recordings = recordings
+ ?.map((e) => JumpRecordResult(
+ jumps: int.parse(e.split(',')[0]),
+ duration: Duration(seconds: int.parse(e.split(',')[1]))))
+ .toList() ??
+ [];
+ });
+ }
+
+ /// Starts the timer.
+ void _startTimer() {
+ _timer = Timer.periodic(Duration(seconds: 1), (timer) {
+ setState(() {
+ _duration = _duration + Duration(seconds: 1);
+ });
+ });
+ }
+
+ /// Stops the timer.
+ void _stopTimer() {
+ if (_timer != null) {
+ _timer!.cancel();
+ _duration = Duration();
+ }
+ }
+
+ /// Starts or stops the recording.
+ void startStopRecording() {
+ if (_recording) {
+ setState(() {
+ _recording = false;
+ });
+ _saveResult();
+ _stopTimer();
+ _resetCounter();
+ } else {
+ setState(() {
+ _recording = true;
+ _firstJump = true;
+ _startTimer();
+ _setupListeners();
+ });
+ }
+ }
+
+ /// Resets all counters to 0.
+ void _resetCounter() {
+ _jumps = 0;
+ _avgJumpHeight = 0.0;
+ }
+
+ /// Saves the result of the recording.
+ void _saveResult() {
+ if (_recordings.length >= _maxSavedItems) {
+ _recordings.removeAt(0);
+ }
+ _recordings.add(JumpRecordResult(jumps: _jumps, duration: _duration));
+
+ /// Save the recordings to shared preferences.
+ saveJumpRecordings();
+ }
+
+ /// Saves the recordings to shared preferences.
+ void saveJumpRecordings() async {
+ SharedPreferences prefs = await SharedPreferences.getInstance();
+ prefs.setStringList('jumpRecordings',
+ _recordings.map((e) => '${e.jumps},${e.duration.inSeconds}').toList());
+ }
+
+ /// Formats the duration to a string.
+ String _formatDuration(Duration duration) {
+ String twoDigits(int n) => n.toString().padLeft(2, '0');
+ String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
+ String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
+ return "$twoDigitMinutes:$twoDigitSeconds";
+ }
+
+ /// Builds the sensor config.
+ OpenEarableSensorConfig _buildSensorConfig() {
+ return OpenEarableSensorConfig(
+ sensorId: 0,
+ samplingRate: _samplingRate,
+ latency: 0,
+ );
+ }
+
+ /// Sets up listeners for sensor data.
+ _setupListeners() {
+ _imuSubscription =
+ _openEarable.sensorManager.subscribeToSensorData(0).listen((data) {
+ /// If the recording is stopped, stop processing sensor data.
+ if (!_recording) {
+ return;
+ }
+ _processSensorData(data);
+ });
+ }
+
+ /// Processes the sensor data.
+ void _processSensorData(Map data) {
+ double _accX = data["ACC"]["X"];
+ double _accY = data["ACC"]["Y"];
+ double _accZ = data["ACC"]["Z"];
+ double accMagnitude =
+ _accZ.sign * sqrt(_accX * _accX + _accY * _accY + _accZ * _accZ);
+ double currentAcc = accMagnitude - _gravity;
+
+ _updateJumps(currentAcc);
+ }
+
+ double maxAcc = -double.infinity;
+
+ /// Updates the number of jumps.
+ Future _updateJumps(double currentAcc) async {
+ if (currentAcc > 7.5 && !_detectedJump) {
+ setState(() {
+ if (!_firstJump) {
+ _jumps++;
+ } else {
+ _firstJump = false;
+ }
+ });
+ _detectedJump = true;
+ } else if (currentAcc < 0) {
+ _detectedJump = false;
+ }
+ }
+
+ /// Builds the UI for the JumpRopeCounter widget.
+ @override
+ Widget build(BuildContext context) {
+ return DefaultTabController(
+ length: _tabAmount,
+ child: Scaffold(
+ backgroundColor: Theme.of(context).colorScheme.background,
+ appBar: AppBar(
+ title: Text('Jump Rope Counter'),
+ bottom: TabBar(
+ controller: _tabController,
+ indicator: BoxDecoration(
+ borderRadius: BorderRadius.circular(8), // Creates border
+ color: Colors.greenAccent),
+ tabs: [
+ Tab(text: "Record"),
+ Tab(text: "Jump Activity"),
+ ],
+ ),
+ ),
+ body: TabBarView(
+ controller: _tabController,
+ children: [
+ /// If the earable is not connected, show a warning. Otherwise show the jump counter.
+ _openEarable.bleManager.connected
+ ? _ropeCounterWidget()
+ : EarableNotConnectedWarning(),
+ _ropeSkipHistoryWidget(),
+ ],
+ ),
+ ),
+ );
+ }
+
+ /// Builds the UI for the jump counter.
+ Widget _ropeCounterWidget() {
+ return Center(
+ child: Column(mainAxisAlignment: MainAxisAlignment.start, children: [
+ Column(children: [
+ SizedBox(height: 32),
+ Text(
+ "Jumps",
+ style: TextStyle(
+ fontSize: 26,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ Padding(
+ padding: EdgeInsets.fromLTRB(16, 0, 16, 64),
+ child: Text(
+ "$_jumps",
+ style: TextStyle(
+ fontFamily: 'Digital',
+ // This is a common monospaced font
+ fontSize: 100,
+ fontWeight: FontWeight.normal,
+ ),
+ ),
+ ),
+ ]),
+ Padding(
+ padding: EdgeInsets.fromLTRB(8, 0, 8, 0),
+ child: Column(
+ children: [
+ Padding(
+ padding: EdgeInsets.fromLTRB(16, 0, 32, 0),
+ child: Text("Time")),
+ Padding(
+ padding: EdgeInsets.fromLTRB(16, 0, 32, 0),
+ child: Text(
+ _formatDuration(_duration),
+ style: TextStyle(
+ fontFamily: 'Digital',
+ // This is a common monospaced font
+ fontSize: 60,
+ fontWeight: FontWeight.normal,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ Expanded(
+ child: Align(
+ alignment: Alignment.bottomCenter,
+ child: Padding(
+ padding: EdgeInsets.fromLTRB(32, 0, 32, 64),
+ child: ElevatedButton(
+ onPressed: startStopRecording,
+ style: ElevatedButton.styleFrom(
+ fixedSize: Size(250, 70),
+ backgroundColor: _recording
+ ? Color(0xfff27777)
+ : Theme.of(context).colorScheme.secondary,
+ foregroundColor: Colors.black,
+ ),
+ child: Text(
+ _recording ? "Stop Recording" : "Start Recording",
+ style: TextStyle(fontSize: 20),
+ ),
+ ),
+ ),
+ ))
+ ]));
+ }
+
+ /// Builds the UI for the jump rope history.
+ Widget _ropeSkipHistoryWidget() {
+ return Center(
+ child: Column(
+ children: [
+ SizedBox(height: 16),
+ Center(
+ child: Text(
+ "Your past $_maxSavedItems sessions are saved here.",
+ style: TextStyle(
+ fontSize: 20,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ SizedBox(height: 16),
+ Divider(
+ thickness: 2,
+ ),
+ Expanded(
+ child: _recordings.isEmpty
+ ? Stack(
+ fit: StackFit.expand,
+ children: [
+ Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.info,
+ size: 48,
+ color: Colors.yellow,
+ ),
+ SizedBox(height: 16),
+ Center(
+ child: Text(
+ "No Workouts Found.",
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ )
+ : ListView.separated(
+ itemCount: _recordings.length,
+ physics: BouncingScrollPhysics(),
+ itemBuilder: (context, index) {
+ int _reverseIndex = _recordings.length - index - 1;
+ return _listItem(_reverseIndex);
+ },
+ separatorBuilder: (context, index) {
+ return Divider(
+ thickness: 2,
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// Builds a list item for the jump rope history.
+ /// The list item is a dismissible widget.
+ Widget _listItem(index) {
+ return Dismissible(
+ key: UniqueKey(),
+ direction: DismissDirection.endToStart,
+ background: Container(
+ color: Colors.red,
+ child: Align(
+ child: Padding(
+ padding: const EdgeInsets.only(right: 16),
+ child: Icon(Icons.delete),
+ ),
+ alignment: Alignment.centerRight,
+ ),
+ ),
+ confirmDismiss: (direction) async {
+ if (direction == DismissDirection.endToStart) {
+ bool delete = true;
+ final snackbarController = ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Deleted Entry'),
+ duration: Duration(milliseconds: 2000),
+ action: SnackBarAction(
+ label: 'Undo', onPressed: () => delete = false),
+ ),
+ );
+ await snackbarController.closed;
+ return delete;
+ }
+ return true;
+ },
+ onDismissed: (direction) {
+ setState(() {
+ _recordings.removeAt(index);
+ saveJumpRecordings();
+ });
+ },
+ child: ListTile(
+ title: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Align(
+ alignment: Alignment.centerLeft,
+ child: Padding(
+ padding: EdgeInsets.fromLTRB(0, 0, 0, 20),
+ child: Text(
+ _recordings[index].date,
+ maxLines: 1,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ Column(
+ children: [
+ Text(
+ _recordings[index].jumps.toString(),
+ maxLines: 1,
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ Text(
+ "jump count",
+ maxLines: 1,
+ ),
+ ],
+ ),
+ Column(
+ children: [
+ Text(
+ _recordings[index].duration.toString().substring(2, 7),
+ maxLines: 1,
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ Text(
+ "jump time",
+ maxLines: 1,
+ ),
+ ],
+ ),
+ ],
+ )
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/open_earable/assets/posture_tracker/Head_Front.png b/open_earable/lib/apps_tab/neck_stretch/assets/Head_Front.png
similarity index 100%
rename from open_earable/assets/posture_tracker/Head_Front.png
rename to open_earable/lib/apps_tab/neck_stretch/assets/Head_Front.png
diff --git a/open_earable/assets/posture_tracker/Head_Side.png b/open_earable/lib/apps_tab/neck_stretch/assets/Head_Side.png
similarity index 100%
rename from open_earable/assets/posture_tracker/Head_Side.png
rename to open_earable/lib/apps_tab/neck_stretch/assets/Head_Side.png
diff --git a/open_earable/assets/posture_tracker/Neck_Front.png b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Front.png
similarity index 100%
rename from open_earable/assets/posture_tracker/Neck_Front.png
rename to open_earable/lib/apps_tab/neck_stretch/assets/Neck_Front.png
diff --git a/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Left_Stretch.png b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Left_Stretch.png
new file mode 100644
index 0000000..8562cfb
Binary files /dev/null and b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Left_Stretch.png differ
diff --git a/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Main_Stretch.png b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Main_Stretch.png
new file mode 100644
index 0000000..4aee89f
Binary files /dev/null and b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Main_Stretch.png differ
diff --git a/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Right_Stretch.png b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Right_Stretch.png
new file mode 100644
index 0000000..3408e49
Binary files /dev/null and b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Right_Stretch.png differ
diff --git a/open_earable/assets/posture_tracker/Neck_Side.png b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Side.png
similarity index 100%
rename from open_earable/assets/posture_tracker/Neck_Side.png
rename to open_earable/lib/apps_tab/neck_stretch/assets/Neck_Side.png
diff --git a/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Side_Stretch.png b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Side_Stretch.png
new file mode 100644
index 0000000..9bddf86
Binary files /dev/null and b/open_earable/lib/apps_tab/neck_stretch/assets/Neck_Side_Stretch.png differ
diff --git a/open_earable/lib/apps_tab/neck_stretch/assets/logo.png b/open_earable/lib/apps_tab/neck_stretch/assets/logo.png
new file mode 100644
index 0000000..d970d0d
Binary files /dev/null and b/open_earable/lib/apps_tab/neck_stretch/assets/logo.png differ
diff --git a/open_earable/lib/apps_tab/neck_stretch/assets/logo2.png b/open_earable/lib/apps_tab/neck_stretch/assets/logo2.png
new file mode 100644
index 0000000..c953939
Binary files /dev/null and b/open_earable/lib/apps_tab/neck_stretch/assets/logo2.png differ
diff --git a/open_earable/lib/apps_tab/neck_stretch/assets/neck_stretcher_logo.webp b/open_earable/lib/apps_tab/neck_stretch/assets/neck_stretcher_logo.webp
new file mode 100644
index 0000000..5a9285e
Binary files /dev/null and b/open_earable/lib/apps_tab/neck_stretch/assets/neck_stretcher_logo.webp differ
diff --git a/open_earable/lib/apps_tab/neck_stretch/assets/neck_stretcher_logo2.webp b/open_earable/lib/apps_tab/neck_stretch/assets/neck_stretcher_logo2.webp
new file mode 100644
index 0000000..9130de3
Binary files /dev/null and b/open_earable/lib/apps_tab/neck_stretch/assets/neck_stretcher_logo2.webp differ
diff --git a/open_earable/lib/apps_tab/neck_stretch/model/stretch_colors.dart b/open_earable/lib/apps_tab/neck_stretch/model/stretch_colors.dart
new file mode 100644
index 0000000..8c33e3d
--- /dev/null
+++ b/open_earable/lib/apps_tab/neck_stretch/model/stretch_colors.dart
@@ -0,0 +1,22 @@
+import 'package:flutter/material.dart';
+
+/// Colors used for the arcs around the head
+final Color rightAreaIndicator = Color.fromARGB(255, 195, 195, 195);
+final Color wrongAreaIndicator = Color(0xFF7A7A7A);
+
+/// Colors used for bad stretch directions
+/// Equivalent with Colors.redAccent[100]!
+final Color badStretchColor = Color.fromARGB(255, 255, 138, 128);
+final Color badStretchIndicatorColor = Colors.redAccent[100]!;
+
+/// Colors used for good stretch direction
+final Color goodStretchColor = Color(0xff77F2A1);
+final Color goodStretchIndicatorColor = Colors.greenAccent[100]!;
+
+/// Used to indicate what is currently being stretched
+final Color stretchedAreaColor = Color.fromARGB(255, 0, 186, 255);
+
+/// Colors used for the stretching button
+final Color startButtonColor = Color(0xff77F2A1);
+final Color stopButtonColor = Color(0xfff27777);
+final Color restingButtonColor = Color(0xffffbb3d);
\ No newline at end of file
diff --git a/open_earable/lib/apps_tab/neck_stretch/model/stretch_state.dart b/open_earable/lib/apps_tab/neck_stretch/model/stretch_state.dart
new file mode 100644
index 0000000..7d37f27
--- /dev/null
+++ b/open_earable/lib/apps_tab/neck_stretch/model/stretch_state.dart
@@ -0,0 +1,253 @@
+import 'dart:async';
+
+import 'package:open_earable/apps_tab/neck_stretch/view_model/stretch_view_model.dart';
+import 'package:open_earable_flutter/src/open_earable_flutter.dart';
+
+/// Enum for the neck stretch states
+enum NeckStretchState {
+ mainNeckStretch,
+ leftNeckStretch,
+ rightNeckStretch,
+ noStretch,
+ doneStretching
+}
+
+/// Used to get a String representation for Display of the current meditation state
+extension NeckStretchStateExtension on NeckStretchState {
+ String get display {
+ switch (this) {
+ case NeckStretchState.mainNeckStretch:
+ return 'Main Neck Area';
+ case NeckStretchState.leftNeckStretch:
+ return 'Left Neck Area';
+ case NeckStretchState.rightNeckStretch:
+ return 'Right Neck Area';
+ case NeckStretchState.noStretch:
+ return 'Not Stretching';
+ default:
+ return 'Done Stretching';
+ }
+ }
+
+ /// Gets the corresponding asset path for the front neck image
+ String get assetPathNeckFront {
+ switch (this) {
+ case NeckStretchState.rightNeckStretch:
+ return 'lib/apps_tab/neck_stretch/assets/Neck_Right_Stretch.png';
+ case NeckStretchState.leftNeckStretch:
+ return 'lib/apps_tab/neck_stretch/assets/Neck_Left_Stretch.png';
+ default:
+ return 'lib/apps_tab/neck_stretch/assets/Neck_Front.png';
+ }
+ }
+
+ /// Gets the corresponding asset path for the side eck image
+ String get assetPathNeckSide {
+ switch (this) {
+ case NeckStretchState.mainNeckStretch:
+ return 'lib/apps_tab/neck_stretch/assets/Neck_Main_Stretch.png';
+ default:
+ return 'lib/apps_tab/neck_stretch/assets/Neck_Side.png';
+ }
+ }
+
+ /// Gets the corresponding asset path for the Head Front Image
+ String get assetPathHeadFront {
+ return 'lib/apps_tab/neck_stretch/assets/Head_Front.png';
+ }
+
+ /// Gets the corresponding asset path for the Head Side Image
+ String get assetPathHeadSide {
+ return 'lib/apps_tab/neck_stretch/assets/Head_Side.png';
+ }
+}
+
+/// Stores all data for a stretching session
+class StretchStats {
+ /// Maximum angle reached when doing the main neck stretch
+ double maxMainAngle;
+
+ /// Maximum angle reached on the left neck stretch
+ double maxLeftAngle;
+
+ /// Maximum angle reached on the right neck stretch
+ double maxRightAngle;
+
+ /// Duration over set main angle threshold
+ double mainStretchDuration;
+
+ /// Duration over set side angle threshold
+ double leftStretchDuration;
+ double rightStretchDuration;
+
+ StretchStats(
+ {this.maxMainAngle = 0,
+ this.maxLeftAngle = 0,
+ this.maxRightAngle = 0,
+ this.mainStretchDuration = 0,
+ this.leftStretchDuration = 0,
+ this.rightStretchDuration = 0});
+
+ void clear() {
+ this.maxMainAngle = 0;
+ this.maxLeftAngle = 0;
+ this.maxRightAngle = 0;
+ this.mainStretchDuration = 0;
+ this.leftStretchDuration = 0;
+ this.rightStretchDuration = 0;
+ }
+}
+
+/// Stores all settings needed to manage a stretching session
+class StretchSettings {
+ NeckStretchState state;
+
+ /// Duration for the main neck relaxation
+ Duration mainNeckRelaxation;
+
+ /// Duration for the left neck relaxation
+ Duration leftNeckRelaxation;
+
+ /// Duration for the right neck relaxation
+ Duration rightNeckRelaxation;
+
+ /// Time used for resting between each set
+ Duration restingTime;
+
+ /// Angle used for stretching forward
+ double forwardStretchAngle;
+
+ /// Angle used for stretching sideways
+ double sideStretchAngle;
+
+ /// The stretch settings containing duration timers and state
+ StretchSettings({
+ this.state = NeckStretchState.noStretch,
+ required this.mainNeckRelaxation,
+ required this.leftNeckRelaxation,
+ required this.rightNeckRelaxation,
+ required this.restingTime,
+ required this.forwardStretchAngle,
+ required this.sideStretchAngle,
+ });
+}
+
+/// Stores all data and functions to manage the guided neck meditation
+class NeckStretch {
+ StretchSettings _settings = StretchSettings(
+ mainNeckRelaxation: Duration(seconds: 30),
+ leftNeckRelaxation: Duration(seconds: 30),
+ rightNeckRelaxation: Duration(seconds: 30),
+ restingTime: Duration(seconds: 5),
+ forwardStretchAngle: 45,
+ sideStretchAngle: 30);
+
+ final OpenEarable _openEarable;
+ final StretchViewModel _viewModel;
+
+ /// Defines whether you are currently resting between two stretch exercises
+ late bool _resting;
+
+ /// Holds the Timer that increments the current Duration
+ Timer? _restDurationTimer;
+
+ /// Stores the rest duration of the current timer
+ late Duration _restDuration;
+
+ /// Stores the current active timer for state transition
+ Timer? _currentTimer;
+
+ StretchSettings get settings => _settings;
+
+ Duration get restDuration => _restDuration;
+
+ bool get resting => _resting;
+
+ set settings(StretchSettings settings) => _settings = settings;
+
+ NeckStretch(this._openEarable, this._viewModel) {
+ this._restDuration = Duration(seconds: 0);
+ this._resting = false;
+ }
+
+ /// Starts the Meditation with the according timers
+ void startStretching() {
+ _resting = false;
+ _viewModel.startTracking();
+ _settings.state = NeckStretchState.noStretch;
+ _setNextState();
+ }
+
+ /// Stops the current Meditation
+ void stopStretching() {
+ _resting = false;
+ _settings.state = NeckStretchState.noStretch;
+ _currentTimer?.cancel();
+ _restDurationTimer?.cancel();
+ _restDuration = Duration(seconds: 0);
+ _viewModel.stopTracking();
+ }
+
+ /// Starts the countdown for restDuration
+ void _startCountdown() {
+ _restDurationTimer = Timer.periodic(Duration(seconds: 1), (timer) {
+ _restDuration -= Duration(seconds: 1);
+ });
+ }
+
+ /// Sets the state and timers for the state.
+ void _setState(NeckStretchState state, Duration stateDuration) {
+ _settings.state = state;
+ // If you just swapped to this state, first rest for restingTime, then set new state
+ if (_resting) {
+ /// If we don't restart the timer it results in a weird UI inconsistency
+ /// for displaying the _restDuration as then the restDuration is already
+ /// counted down when the next Timer hasn't started yet.
+ _restDurationTimer?.cancel();
+ _startCountdown();
+ _restDuration = _settings.restingTime;
+ _currentTimer = Timer(_settings.restingTime, () {
+ _resting = false;
+ _setState(state, stateDuration);
+ _openEarable.audioPlayer.jingle(8);
+ });
+ } else {
+ _restDuration = stateDuration;
+ _currentTimer = Timer(stateDuration, _setNextState);
+ }
+ }
+
+ /// Used to set the next stretch state and set the correct Timers
+ void _setNextState() {
+ switch (_settings.state) {
+ case NeckStretchState.noStretch:
+ case NeckStretchState.doneStretching:
+ _startCountdown();
+ _setState(
+ NeckStretchState.mainNeckStretch, _settings.mainNeckRelaxation);
+ return;
+ case NeckStretchState.mainNeckStretch:
+ _resting = true;
+ _setState(
+ NeckStretchState.rightNeckStretch, _settings.rightNeckRelaxation);
+ _openEarable.audioPlayer.jingle(2);
+ return;
+ case NeckStretchState.rightNeckStretch:
+ _resting = true;
+ _setState(
+ NeckStretchState.leftNeckStretch, _settings.leftNeckRelaxation);
+ _openEarable.audioPlayer.jingle(2);
+ return;
+ case NeckStretchState.leftNeckStretch:
+ _settings.state = NeckStretchState.doneStretching;
+ _currentTimer?.cancel();
+ _restDurationTimer?.cancel();
+ _restDuration = Duration(seconds: 0);
+ _viewModel.stopTracking();
+ _openEarable.audioPlayer.jingle(2);
+ return;
+ default:
+ return;
+ }
+ }
+}
diff --git a/open_earable/lib/apps_tab/neck_stretch/view/stretch_app_view.dart b/open_earable/lib/apps_tab/neck_stretch/view/stretch_app_view.dart
new file mode 100644
index 0000000..0dfc38b
--- /dev/null
+++ b/open_earable/lib/apps_tab/neck_stretch/view/stretch_app_view.dart
@@ -0,0 +1,142 @@
+import 'package:flutter/material.dart';
+
+import 'package:open_earable/apps_tab/posture_tracker/model/attitude_tracker.dart';
+import 'package:open_earable/apps_tab/neck_stretch/view/stretch_tracker_view.dart';
+import 'package:open_earable/apps_tab/neck_stretch/view/stretch_tutorial_view.dart';
+import 'package:open_earable/apps_tab/neck_stretch/view_model/stretch_view_model.dart';
+import 'package:open_earable/apps_tab/neck_stretch/model/stretch_state.dart';
+import 'package:open_earable/apps_tab/neck_stretch/view/stretch_settings_view.dart';
+import 'package:open_earable/apps_tab/neck_stretch/view/stretch_stats_view.dart';
+import 'package:open_earable/shared/global_theme.dart';
+
+import 'package:open_earable_flutter/src/open_earable_flutter.dart';
+
+class MenuItem {
+ final IconData iconData;
+ final String title;
+ final String description;
+ final VoidCallback onTap;
+
+ MenuItem(
+ {required this.iconData,
+ required this.title,
+ required this.description,
+ required this.onTap});
+}
+
+class StretchAppView extends StatefulWidget {
+ final AttitudeTracker _tracker;
+ final OpenEarable _openEarable;
+
+ StretchAppView(this._tracker, this._openEarable);
+
+ @override
+ State createState() => _StretchAppViewState();
+}
+
+/// This class is the initial view you get when opening the Stretch-App
+/// It refers to all the submodules of the stretching app
+class _StretchAppViewState extends State {
+ late final StretchViewModel _viewModel;
+
+ @override
+ void initState() {
+ super.initState();
+ this._viewModel = StretchViewModel(widget._tracker, widget._openEarable);
+ }
+
+ List