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 stretchApps(BuildContext context) { + return [ + MenuItem( + iconData: Icons.play_circle, + title: "Start Stretching", + description: "Dive directly into the guided neck stretch!", + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Material( + child: Theme( + data: materialTheme, + child: StretchTrackerView(this._viewModel))))); + }), + MenuItem( + iconData: Icons.info, + title: "Stretch Stats", + description: "Your stats about your last stretch.", + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Material( + child: Theme( + data: materialTheme, + child: StretchStatsView(this._viewModel))))); + }), + MenuItem( + iconData: Icons.help, + title: "How to use this Tool", + description: "Short guide to get started with the neck stretch.", + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Material( + child: Theme( + data: materialTheme, + child: StretchTutorialView(this._viewModel))))); + }), + // ... similarly for other apps + ]; + } + + @override + Widget build(BuildContext context) { + List apps = stretchApps(context); + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar( + title: const Text("Guided Neck Stretch"), + actions: [ + /// Settings button, only active when not stretching + IconButton( + onPressed: (this._viewModel.stretchState == + NeckStretchState.noStretch || + this._viewModel.stretchState == + NeckStretchState.doneStretching) + ? () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => Material( + child: Theme( + data: materialTheme, + child: SettingsView(this._viewModel))))) + : null, + icon: Icon(Icons.settings)), + ], + ), + body: 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: Container( + alignment: Alignment.center, + height: 80, + 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/neck_stretch/view/stretch_arc_painter.dart b/open_earable/lib/apps_tab/neck_stretch/view/stretch_arc_painter.dart new file mode 100644 index 0000000..abf15f6 --- /dev/null +++ b/open_earable/lib/apps_tab/neck_stretch/view/stretch_arc_painter.dart @@ -0,0 +1,214 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:open_earable/apps_tab/neck_stretch/model/stretch_state.dart'; +import 'package:open_earable/apps_tab/posture_tracker/view/arc_painter.dart'; +import 'package:open_earable/apps_tab/neck_stretch/model/stretch_colors.dart'; + +class StretchArcPainter extends CustomPainter { + /// the angle of rotation + final double angle; + final double angleThreshold; + final NeckStretchState stretchState; + final bool isFront; + + StretchArcPainter( + {required this.angle, + this.angleThreshold = 0, + this.stretchState = NeckStretchState.noStretch, + required this.isFront}); + + @override + void paint(Canvas canvas, Size size) { + Paint circlePaint = Paint() + ..color = rightAreaIndicator + ..style = PaintingStyle.stroke + ..strokeWidth = 5.0; + + Path circlePath = Path(); + circlePath.addOval(Rect.fromCircle( + center: Offset(size.width / 2, size.height / 2), + radius: min(size.width, size.height) / 2)); + canvas.drawPath(circlePath, circlePaint); + + // Create a paint object with the right color for the stretch indicator + Paint anglePaint = Paint() + ..color = _getIndicatorColor() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = 5.0; + + // Create a path object to draw the arc + Path anglePath = Path(); + + // Calculate the center and radius of the circle + Offset center = Offset(size.width / 2, size.height / 2); + double radius = min(size.width, size.height) / 2; + + // Calculate the start and end angles of the arc + double startAngle = -pi / 2; // start from the top of the circle + double endAngle = angle; + + // Add an arc to the path + anglePath.addArc( + Rect.fromCircle(center: center, radius: radius), + // create a rectangle from the center and radius + startAngle, // start angle + endAngle, // sweep angle + ); + + /// Draw the overshooting path + Path angleOvershootPath = Path(); + + if (_isNegativeOvershoot()) { + angleOvershootPath.addArc( + Rect.fromCircle(center: center, radius: radius), + // create a rectangle from the center and radius + startAngle + angle.sign * angleThreshold, // start angle + angle.sign * (angle.abs() - angleThreshold), // sweep angle + ); + } else { + angleOvershootPath.addArc( + Rect.fromCircle(center: center, radius: radius), + // create a rectangle from the center and radius + startAngle + angle.sign * angleThreshold, // start angle + // If you are facing the wrong direction you don't need to draw this + !_isWrongStretchDirection() + ? angle.sign * (angle.abs() - angleThreshold) + : 0, // sweep angle + ); + } + + Paint angleOvershootPaint = Paint() + ..color = _getOvershootColor() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = 5.0; + + Path thresholdPath = Path(); + thresholdPath.addArc( + Rect.fromCircle(center: center, radius: radius), + // create a rectangle from the center and radius + _getStartAngle(startAngle, angleThreshold), // start angle + _getThreshold(angleThreshold), // sweep angle + ); + + Paint thresholdPaint = Paint() + ..color = _getThresholdColor() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = 5.0; + + // Draw the path on the canvas + canvas.drawPath(thresholdPath, thresholdPaint); + canvas.drawPath(anglePath, anglePaint); + if (angle.abs() > angleThreshold.abs()) { + canvas.drawPath(angleOvershootPath, angleOvershootPaint); + } + } + + /// Gets the right start angle depending on stretch state + double _getStartAngle(double startAngle, double threshold) { + if (!this.isFront) return startAngle - threshold; + + switch (this.stretchState) { + case NeckStretchState.rightNeckStretch: + return startAngle - threshold; + case NeckStretchState.leftNeckStretch: + return startAngle - (0.775 * pi); + default: + return startAngle - threshold; + } + } + + /// Gets the right threshold depending on stretch state + double _getThreshold(double threshold) { + if (this.isFront) { + switch (this.stretchState) { + case NeckStretchState.rightNeckStretch: + case NeckStretchState.leftNeckStretch: + return threshold + + (0.775 * + pi); // Will place the dark grey area till the start of the neck + default: + return 2 * threshold; + } + } + + switch (this.stretchState) { + case NeckStretchState.mainNeckStretch: + return threshold + + (0.8 * + pi); // Will place the dark grey area till the start of the neck + default: + return 2 * threshold; + } + } + + /// Determines whether the user is currently stretching in the right direction + bool _isWrongStretchDirection() { + if (this.isFront) { + switch (this.stretchState) { + case NeckStretchState.rightNeckStretch: + return angle.sign >= 0; + case NeckStretchState.leftNeckStretch: + return angle.sign <= 0; + default: + return false; + } + } + + switch (this.stretchState) { + case NeckStretchState.mainNeckStretch: + return angle.sign >= 0; + default: + return false; + } + } + + /// Detgermines whether the overshoot is negative (shouldnt stretch that part) + /// or is positive (should stretch that part) + bool _isNegativeOvershoot() { + return (this.isFront && + this.stretchState == NeckStretchState.mainNeckStretch) || + (!this.isFront && + (this.stretchState == NeckStretchState.leftNeckStretch || + this.stretchState == NeckStretchState.rightNeckStretch)); + } + + /// Returns the right color for the overshoot depending on stretch state and + /// if its upper or lower head state arc. + Color _getOvershootColor() { + if (_isNegativeOvershoot()) { + // Equals Colors.redAccent[100]! + return badStretchColor; + } + + return goodStretchColor; + } + + /// Returns the right color for the threshold depending on stretch state and + /// if its the upper or lower head state arc. + Color _getThresholdColor() { + if (_isNegativeOvershoot()) { + return goodStretchIndicatorColor; + } + + return wrongAreaIndicator; + } + + /// Gets the right indicator color depending on stretch angle and part + Color _getIndicatorColor() { + if (_isNegativeOvershoot()) return goodStretchColor; + + if (_isWrongStretchDirection()) return badStretchIndicatorColor; + + return goodStretchIndicatorColor; + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + // check if oldDelegate is an ArcPainter and if the angle is the same + return oldDelegate is ArcPainter && oldDelegate.angle != this.angle; + } +} diff --git a/open_earable/lib/apps_tab/neck_stretch/view/stretch_roll_view.dart b/open_earable/lib/apps_tab/neck_stretch/view/stretch_roll_view.dart new file mode 100644 index 0000000..31d8c6d --- /dev/null +++ b/open_earable/lib/apps_tab/neck_stretch/view/stretch_roll_view.dart @@ -0,0 +1,72 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:open_earable/apps_tab/neck_stretch/model/stretch_state.dart'; +import 'package:open_earable/apps_tab/neck_stretch/view/stretch_arc_painter.dart'; + +/// A widget that displays the roll of the head and neck for the meditation. +class StretchRollView extends StatelessWidget { + static final double _MAX_ROLL = pi / 2; + + /// The roll of the head and neck in radians. + final double roll; + final double angleThreshold; + + final String headAssetPath; + final String neckAssetPath; + final AlignmentGeometry headAlignment; + + // Checks whether the arc has different properties due to meditation state + final NeckStretchState stretchState; + + const StretchRollView( + {Key? key, + required this.roll, + this.angleThreshold = 0, + required this.headAssetPath, + required this.neckAssetPath, + this.headAlignment = Alignment.center, + this.stretchState = NeckStretchState.noStretch}) + : super(key: key); + + /// Returns true if this is a StretchRollView for a front facing head. False otherwise. + bool _isFront() { + return headAssetPath.contains("Front.png"); + } + + @override + Widget build(BuildContext context) { + return Column(children: [ + Text("${(this.roll * 180 / 3.14).abs().toStringAsFixed(0)}°", + style: TextStyle( + // use proper color matching the background + color: Theme.of(context).colorScheme.onBackground, + fontSize: 30, + fontWeight: FontWeight.bold)), + CustomPaint( + painter: StretchArcPainter( + angle: this.roll, + angleThreshold: this.angleThreshold, + stretchState: this.stretchState, + isFront: _isFront()), + child: Padding( + padding: EdgeInsets.all(10), + child: ClipOval( + child: Container( + color: roll.abs() > _MAX_ROLL + ? Colors.red.withOpacity(0.5) + : Colors.transparent, + child: Stack(children: [ + Image.asset(this.neckAssetPath), + Transform.rotate( + angle: this.roll.isFinite + ? roll.abs() < _MAX_ROLL + ? this.roll + : roll.sign * _MAX_ROLL + : 0, + alignment: this.headAlignment, + child: Image.asset(this.headAssetPath)), + ]))))), + ]); + } +} diff --git a/open_earable/lib/apps_tab/neck_stretch/view/stretch_settings_view.dart b/open_earable/lib/apps_tab/neck_stretch/view/stretch_settings_view.dart new file mode 100644 index 0000000..213ffa3 --- /dev/null +++ b/open_earable/lib/apps_tab/neck_stretch/view/stretch_settings_view.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:open_earable/apps_tab/neck_stretch/model/stretch_state.dart'; +import 'package:open_earable/apps_tab/neck_stretch/view_model/stretch_view_model.dart'; +import 'package:provider/provider.dart'; +import 'dart:core'; + +class SettingsView extends StatefulWidget { + final StretchViewModel _viewModel; + + SettingsView(this._viewModel); + + @override + State createState() => _SettingsViewState(); +} + +class _SettingsViewState extends State { + late final TextEditingController _mainNeckDuration; + late final TextEditingController _leftNeckDuration; + late final TextEditingController _rightNeckDuration; + late final TextEditingController _restingDuration; + late final TextEditingController _forwardStretchAngle; + late final TextEditingController _sideStretchAngle; + + late final StretchViewModel _viewModel; + + @override + void initState() { + super.initState(); + this._viewModel = widget._viewModel; + _mainNeckDuration = TextEditingController( + text: + _viewModel.stretchSettings.mainNeckRelaxation.inSeconds.toString()); + _leftNeckDuration = TextEditingController( + text: + _viewModel.stretchSettings.leftNeckRelaxation.inSeconds.toString()); + _rightNeckDuration = TextEditingController( + text: _viewModel.stretchSettings.rightNeckRelaxation.inSeconds + .toString()); + _restingDuration = TextEditingController( + text: _viewModel.stretchSettings.restingTime.inSeconds.toString()); + _forwardStretchAngle = TextEditingController( + text: _viewModel.stretchSettings.forwardStretchAngle.toString()); + _sideStretchAngle = TextEditingController( + text: _viewModel.stretchSettings.sideStretchAngle.toString()); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar(title: const Text("Stretch Settings")), + body: ChangeNotifierProvider.value( + value: _viewModel, + builder: (context, child) => Consumer( + builder: (context, postureTrackerViewModel, child) => + _buildSettingsView(), + )), + ); + } + + /// Creates the actual settings view + Widget _buildSettingsView() { + return SingleChildScrollView( + child: Column( + children: [ + Card( + color: Theme.of(context).colorScheme.primary, + child: ListTile( + title: Text("OpenEarable"), + trailing: Text(_viewModel.isTracking + ? "Tracking" + : _viewModel.isAvailable + ? "Available" + : "Unavailable"), + ), + ), + Card( + color: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + /// Settings for all timers used + ListTile( + title: Text("Timers"), + ), + ListTile( + title: Text("Main Neck Relaxation Duration\n(in seconds)"), + trailing: SizedBox( + height: 37.0, + width: 62.0, + child: TextField( + controller: _mainNeckDuration, + textAlign: TextAlign.end, + style: TextStyle(color: Colors.black), + decoration: InputDecoration( + contentPadding: EdgeInsets.all(10), + floatingLabelBehavior: FloatingLabelBehavior.never, + border: OutlineInputBorder(), + labelText: 'Seconds', + filled: true, + labelStyle: TextStyle(color: Colors.black), + fillColor: Colors.white), + keyboardType: TextInputType.number, + onChanged: (_) { + _updateMeditationSettings(); + }, + ), + ), + ), + ListTile( + title: Text("Right Neck Relaxation Duration\n(in seconds)"), + trailing: SizedBox( + height: 37.0, + width: 62.0, + child: TextField( + controller: _rightNeckDuration, + textAlign: TextAlign.end, + style: TextStyle(color: Colors.black), + decoration: InputDecoration( + contentPadding: EdgeInsets.all(10), + floatingLabelBehavior: FloatingLabelBehavior.never, + border: OutlineInputBorder(), + labelText: 'Seconds', + filled: true, + labelStyle: TextStyle(color: Colors.black), + fillColor: Colors.white), + keyboardType: TextInputType.number, + onChanged: (_) { + _updateMeditationSettings(); + }, + ), + ), + ), + ListTile( + title: Text("Left Neck Relaxation Duration\n(in seconds)"), + trailing: SizedBox( + height: 37.0, + width: 62.0, + child: TextField( + controller: _leftNeckDuration, + textAlign: TextAlign.end, + style: TextStyle(color: Colors.black), + decoration: InputDecoration( + contentPadding: EdgeInsets.all(10), + floatingLabelBehavior: FloatingLabelBehavior.never, + border: OutlineInputBorder(), + labelText: 'Seconds', + filled: true, + labelStyle: TextStyle(color: Colors.black), + fillColor: Colors.white), + keyboardType: TextInputType.number, + onChanged: (_) { + _updateMeditationSettings(); + }, + ), + ), + ), + ListTile( + title: + Text("Resting Duration between exercises\n(in seconds)"), + trailing: SizedBox( + height: 37.0, + width: 62.0, + child: TextField( + controller: _restingDuration, + textAlign: TextAlign.end, + style: TextStyle(color: Colors.black), + decoration: InputDecoration( + contentPadding: EdgeInsets.all(10), + floatingLabelBehavior: FloatingLabelBehavior.never, + border: OutlineInputBorder(), + labelText: 'Seconds', + filled: true, + labelStyle: TextStyle(color: Colors.black), + fillColor: Colors.white), + keyboardType: TextInputType.number, + onChanged: (_) { + _updateMeditationSettings(); + }, + ), + ), + ), + ], + ), + ), + Card( + color: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + /// Settings for all timers used + ListTile( + title: Text("Stretch Thresholds"), + ), + ListTile( + title: Text("Main Neck Stretch Goal\n(as an angle)"), + trailing: SizedBox( + height: 37.0, + width: 62.0, + child: TextField( + controller: _forwardStretchAngle, + textAlign: TextAlign.end, + style: TextStyle(color: Colors.black), + decoration: InputDecoration( + contentPadding: EdgeInsets.all(10), + floatingLabelBehavior: FloatingLabelBehavior.never, + border: OutlineInputBorder(), + labelText: 'Angle', + filled: true, + labelStyle: TextStyle(color: Colors.black), + fillColor: Colors.white), + keyboardType: TextInputType.number, + onChanged: (_) { + _updateMeditationSettings(); + }, + ), + ), + ), + ListTile( + title: Text("Side Neck Stretch Goal\n(as an angle)"), + trailing: SizedBox( + height: 37.0, + width: 62.0, + child: TextField( + controller: _sideStretchAngle, + textAlign: TextAlign.end, + style: TextStyle(color: Colors.black), + decoration: InputDecoration( + contentPadding: EdgeInsets.all(10), + floatingLabelBehavior: FloatingLabelBehavior.never, + border: OutlineInputBorder(), + labelText: 'Angle', + filled: true, + labelStyle: TextStyle(color: Colors.black), + fillColor: Colors.white), + keyboardType: TextInputType.number, + onChanged: (_) { + _updateMeditationSettings(); + }, + ), + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.all(8.0), + child: Row(children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _viewModel.isTracking + ? Colors.green[300] + : Colors.blue[300], + foregroundColor: Colors.black, + ), + onPressed: _viewModel.isTracking + ? () { + _viewModel.calibrate(); + _viewModel.stopTracking(); + } + : () => _viewModel.startTracking(), + child: Text(_viewModel.isTracking + ? "Calibrate as Main Posture" + : "Start Calibration"), + ), + ) + ]), + ), + ], + ), + ); + } + + /// Returns the new duration acquired from the Text. + /// Checks if the string is valid (doesn't contain '-' or '.'. + /// Maximum allows time of 59 Minute 59 Seconds for UI consistency, if its more it sets 59 Minutes 59 Seconds + Duration _getNewDuration(Duration duration, String newDuration) { + if (newDuration.contains('.') || newDuration.contains('-')) return duration; + + int parsed = int.parse(newDuration); + + return parsed > 3599 ? Duration(seconds: 3599) : Duration(seconds: parsed); + } + + double _parseAngle(double old, String input) { + if (input.contains('-')) return old; + + return double.parse(input); + } + + /// Update the Meditation Settings according to values, if field is empty set that timer Duration to 0 + void _updateMeditationSettings() { + StretchSettings settings = _viewModel.stretchSettings; + settings.mainNeckRelaxation = + _getNewDuration(settings.mainNeckRelaxation, _mainNeckDuration.text); + settings.rightNeckRelaxation = + _getNewDuration(settings.rightNeckRelaxation, _rightNeckDuration.text); + settings.leftNeckRelaxation = + _getNewDuration(settings.leftNeckRelaxation, _leftNeckDuration.text); + settings.restingTime = + _getNewDuration(settings.restingTime, _restingDuration.text); + settings.forwardStretchAngle = + _parseAngle(settings.forwardStretchAngle, _forwardStretchAngle.text); + settings.sideStretchAngle = + _parseAngle(settings.sideStretchAngle, _sideStretchAngle.text); + } + + @override + void dispose() { + super.dispose(); + } +} diff --git a/open_earable/lib/apps_tab/neck_stretch/view/stretch_stats_view.dart b/open_earable/lib/apps_tab/neck_stretch/view/stretch_stats_view.dart new file mode 100644 index 0000000..ea83867 --- /dev/null +++ b/open_earable/lib/apps_tab/neck_stretch/view/stretch_stats_view.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:open_earable/apps_tab/neck_stretch/model/stretch_state.dart'; +import 'package:open_earable/apps_tab/neck_stretch/view_model/stretch_view_model.dart'; + +class StretchStatsView extends StatefulWidget { + final StretchViewModel _viewModel; + + StretchStatsView(this._viewModel); + + @override + State createState() => _StretchStatsViewState(); +} + +/// Stateful Widget to display the current stretching stats of the most recent stretch +class _StretchStatsViewState extends State { + /// The stretching stats + late StretchStats _stats; + + @override + void initState() { + super.initState(); + this._stats = widget._viewModel.stretchStats; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar(title: const Text("Stretch Stats")), + body: SingleChildScrollView( + child: Column( + children: [ + Card( + color: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + ListTile( + title: Text("Maximum Stretch Angle Achieved"), + ), + ListTile( + title: Text("Main Stretch Max Angle:"), + trailing: Text( + "${(_stats.maxMainAngle * 180 / 3.14).abs().toStringAsFixed(0)}°"), + ), + ListTile( + title: Text("Right Stretch Max Angle:"), + trailing: Text( + "${(_stats.maxRightAngle * 180 / 3.14).abs().toStringAsFixed(0)}°"), + ), + ListTile( + title: Text("Left Stretch Max Angle:"), + trailing: Text( + "${(_stats.maxLeftAngle * 180 / 3.14).abs().toStringAsFixed(0)}°"), + ), + ], + ), + ), + Card( + color: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + ListTile( + title: Text("Stretch Duration over Threshold Angle"), + ), + ListTile( + title: Text("Main Neck Stretch Duration"), + trailing: Text( + "${_stats.mainStretchDuration.toStringAsFixed(2)} s"), + ), + ListTile( + title: Text("Right Neck Stretch Duration"), + trailing: Text( + "${_stats.rightStretchDuration.toStringAsFixed(2)} s"), + ), + ListTile( + title: Text("Left Neck Stretch Duration"), + trailing: Text( + "${_stats.leftStretchDuration.toStringAsFixed(2)} s"), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/open_earable/lib/apps_tab/neck_stretch/view/stretch_tracker_view.dart b/open_earable/lib/apps_tab/neck_stretch/view/stretch_tracker_view.dart new file mode 100644 index 0000000..6194000 --- /dev/null +++ b/open_earable/lib/apps_tab/neck_stretch/view/stretch_tracker_view.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:open_earable/apps_tab/neck_stretch/view/stretch_roll_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/model/stretch_colors.dart'; + +class StretchTrackerView extends StatefulWidget { + final StretchViewModel _viewModel; + + StretchTrackerView(this._viewModel); + + @override + State createState() => _StretchTrackerViewState(); + + /// Builds the actual head views using the StretchRollView + static Widget buildHeadView( + String headAssetPath, + String neckAssetPath, + AlignmentGeometry headAlignment, + double roll, + double angleThreshold, + NeckStretchState state) { + return Padding( + padding: const EdgeInsets.all(5), + child: StretchRollView( + roll: roll, + angleThreshold: angleThreshold * 3.14 / 180, + headAssetPath: headAssetPath, + neckAssetPath: neckAssetPath, + headAlignment: headAlignment, + stretchState: state, + ), + ); + } +} + +class _StretchTrackerViewState extends State { + late final StretchViewModel _viewModel; + + @override + void initState() { + super.initState(); + this._viewModel = widget._viewModel; + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: _viewModel, + builder: (context, child) => Consumer( + builder: (context, neckStretchViewModel, child) => Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar( + /// Override leading back arrow button to stop tracking if + /// user stopped stretching + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + neckStretchViewModel.isTracking + ? _stopStretching() + : () {}; + Navigator.of(context).pop(); + }), + title: const Text("Guided Neck Stretch"), + actions: [ + IconButton( + + /// Settings button, only active when not stretching + onPressed: (this._viewModel.stretchState == + NeckStretchState.noStretch || + this._viewModel.stretchState == + NeckStretchState.doneStretching) + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + SettingsView(this._viewModel))) + : null, + icon: Icon(Icons.settings)), + ], + ), + body: Center( + child: this._buildContentView(neckStretchViewModel), + ), + ))); + } + + /// Used to start stretching via the button + void _startStretching() { + this._viewModel.neckStretch.startStretching(); + } + + /// Used to stop stretching via the button + void _stopStretching() { + this._viewModel.neckStretch.stopStretching(); + } + + /// Returns the TextSpan representing the Status Text at the top of the app + TextSpan _getStatusText() { + if (!_viewModel.isAvailable) + return TextSpan( + text: "Connect an Earable to start Stretching!", + style: TextStyle( + color: Colors.red, + fontSize: 12, + ), + ); + + if (_viewModel.stretchState == NeckStretchState.noStretch) + return TextSpan(text: "Click the Button below\n to start Stretching!"); + + if (_viewModel.stretchState == NeckStretchState.doneStretching) + return TextSpan(text: "You are done stretching,\n good job!"); + + return TextSpan(children: [ + TextSpan( + text: "Currently Stretching: \n", + ), + TextSpan( + text: this._viewModel.stretchState.display, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: stretchedAreaColor, + ), + ) + ]); + } + + /// Returns the button text displayed within the button. Used to also display + /// the remaining time of each phase + Text _getButtonText() { + if (!_viewModel.isTracking) return Text('Start Stretching'); + + if (_viewModel.stretchState == NeckStretchState.doneStretching || + _viewModel.stretchState == NeckStretchState.noStretch) + return Text('Stop Stretching'); + + return Text(_viewModel.restDuration.toString().substring(2, 7)); + } + + /// Build the actual content you can see in the app + Widget _buildContentView(StretchViewModel neckStretchViewModel) { + var headViews = this._createHeadViews(neckStretchViewModel); + return Column( + children: [ + Padding( + padding: EdgeInsets.all(5), + child: Container( + height: 40, + child: Padding( + padding: EdgeInsets.fromLTRB(0, 8, 0, 0), + child: RichText( + textAlign: TextAlign.center, + text: _getStatusText(), + ), + ), + ), + ), + ...headViews.map( + (e) => FractionallySizedBox( + widthFactor: .6, + child: e, + ), + ), + _buildStretchButton(neckStretchViewModel) + ], + ); + } + + /// Gets the correct background color for the stretching button + Color _getBackgroundColor(StretchViewModel neckStretchViewModel) { + if (neckStretchViewModel.isResting) { + return restingButtonColor; + } + + return !neckStretchViewModel.isTracking + ? startButtonColor + : stopButtonColor; + } + + // Creates the Button used to start the stretch exercise + Widget _buildStretchButton(StretchViewModel neckStretchViewModel) { + return Padding( + padding: EdgeInsets.all(16), + child: Column(children: [ + ElevatedButton( + onPressed: neckStretchViewModel.isAvailable + ? () { + neckStretchViewModel.isTracking + ? _stopStretching() + : _startStretching(); + } + : null, + style: ElevatedButton.styleFrom( + backgroundColor: _getBackgroundColor(neckStretchViewModel), + foregroundColor: Colors.black, + ), + child: _getButtonText(), + ), + ]), + ); + } + + /// Creates the Head Views that display depending on the stretch state. + List _createHeadViews(StretchViewModel neckStretchViewModel) { + return [ + // Visible Head-Displays when not stretching + _buildStretchViews( + NeckStretchState.noStretch, neckStretchViewModel, 0.0, 0.0), + + /// Visible Widgets for the main stretch + _buildStretchViews( + NeckStretchState.mainNeckStretch, + neckStretchViewModel, + 7.0, + (neckStretchViewModel.stretchSettings.forwardStretchAngle % 180)), + + /// Visible Widgets for the right stretch + _buildStretchViews( + NeckStretchState.rightNeckStretch, + neckStretchViewModel, + (neckStretchViewModel.stretchSettings.sideStretchAngle % 180), + 15.0), + + /// Visible Widgets for the left stretch + _buildStretchViews(NeckStretchState.leftNeckStretch, neckStretchViewModel, + (neckStretchViewModel.stretchSettings.sideStretchAngle % 180), 15.0), + ]; + } + + /// Builds the head tracking/stretch view parts for a certain state and thresholds + Visibility _buildStretchViews( + NeckStretchState state, + StretchViewModel neckStretchViewModel, + double frontThreshold, + double sideThreshold) { + var visibility; + if (state == NeckStretchState.noStretch) { + visibility = this._viewModel.stretchState == NeckStretchState.noStretch || + this._viewModel.stretchState == NeckStretchState.doneStretching; + } else { + visibility = this._viewModel.stretchState == state; + } + + return Visibility( + visible: visibility, + child: Column( + children: [ + StretchTrackerView.buildHeadView( + state.assetPathHeadFront, + state.assetPathNeckFront, + Alignment.center.add(Alignment(0, 0.3)), + neckStretchViewModel.attitude.roll, + frontThreshold, + state), + StretchTrackerView.buildHeadView( + state.assetPathHeadSide, + state.assetPathNeckSide, + Alignment.center.add(Alignment(0, 0.3)), + -neckStretchViewModel.attitude.pitch, + sideThreshold, + state), + ], + )); + } +} diff --git a/open_earable/lib/apps_tab/neck_stretch/view/stretch_tutorial_view.dart b/open_earable/lib/apps_tab/neck_stretch/view/stretch_tutorial_view.dart new file mode 100644 index 0000000..77437d5 --- /dev/null +++ b/open_earable/lib/apps_tab/neck_stretch/view/stretch_tutorial_view.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; + +import 'package:open_earable/apps_tab/neck_stretch/model/stretch_colors.dart'; +import 'package:open_earable/apps_tab/neck_stretch/view/stretch_tracker_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'; + +class StretchTutorialView extends StatefulWidget { + final StretchViewModel _viewModel; + + StretchTutorialView(this._viewModel); + + @override + State createState() => _StretchTutorialViewState(); +} + +class _StretchTutorialViewState extends State { + late final StretchViewModel _viewModel; + final YoutubePlayerController _ytController = YoutubePlayerController( + initialVideoId: "H5h54Q0wpps", + flags: YoutubePlayerFlags(mute: false, autoPlay: false)); + + @override + void initState() { + super.initState(); + this._viewModel = widget._viewModel; + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: _viewModel, + builder: (context, child) => Consumer( + builder: (context, neckStretchViewModel, child) => Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar( + /// Override leading back arrow button to stop tracking if + /// user stopped stretching + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + neckStretchViewModel.isTracking + ? neckStretchViewModel.stopTracking() + : () {}; + Navigator.of(context).pop(); + }), + title: const Text("Guided Neck Stretch"), + actions: [ + IconButton( + onPressed: (this._viewModel.stretchState == + NeckStretchState.noStretch || + this._viewModel.stretchState == + NeckStretchState.doneStretching) + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + SettingsView(this._viewModel))) + : null, + icon: Icon(Icons.settings)), + ], + ), + body: Center( + child: SingleChildScrollView( + child: this._buildContentView(neckStretchViewModel), + ), + ), + ))); + } + + /// Build the actual content you can see in the app + Widget _buildContentView(StretchViewModel neckStretchViewModel) { + return Column( + children: [ + /// Card with a YoutubePlayer containing a Video explaining all the stretches + Card( + color: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + ListTile( + title: Text("Video showing the different stretches"), + ), + YoutubePlayer( + controller: _ytController, + bottomActions: [ + CurrentPosition(), + ProgressBar( + isExpanded: true, + ), + ], + ), + ], + ), + ), + + /// Card used to explain the tracking colors + Card( + color: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + ListTile( + title: Text("Explaining the Tracking Colors"), + ), + Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 0), + child: RichText( + textAlign: TextAlign.justify, + text: TextSpan( + children: [ + TextSpan( + text: + 'With these widgets you can you can track your current head positioning. Depending on the color of the area you are supposed to be inside of it or outside of it.\n', + ), + ], + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 0), + child: RichText( + textAlign: TextAlign.justify, + text: TextSpan( + children: [ + TextSpan( + text: 'Green: ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: goodStretchIndicatorColor, + ), + ), + TextSpan( + text: 'Try to keep your head within this area\n\n', + ), + TextSpan( + text: 'Dark Grey: ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: wrongAreaIndicator, + ), + ), + TextSpan( + text: + 'You are currently stretching, try to gently move your head into the ', + ), + TextSpan( + text: 'light grey ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: rightAreaIndicator, + ), + ), + TextSpan( + text: 'area\n\n', + ), + ], + ), + ), + ), + ], + ), + ), + + /// Example of the Tracker in Main Neck Stretch state + Card( + color: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + ListTile( + title: Text('Example: Tracker for Main Neck Stretch'), + titleAlignment: ListTileTitleAlignment.center, + ), + Padding( + padding: EdgeInsets.fromLTRB(0, 0, 0, 16), + child: Column( + children: [ + RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: "Currently Stretching: \n", + ), + TextSpan( + text: NeckStretchState.mainNeckStretch.display, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: stretchedAreaColor, + ), + ), + ], + ), + ), + + /// The head views used for stretching + FractionallySizedBox( + widthFactor: 0.6, + child: StretchTrackerView.buildHeadView( + NeckStretchState.mainNeckStretch.assetPathHeadFront, + NeckStretchState.mainNeckStretch.assetPathNeckFront, + Alignment.center.add(Alignment(0, 0.3)), + neckStretchViewModel.attitude.roll, + 30, + NeckStretchState.mainNeckStretch), + ), + FractionallySizedBox( + widthFactor: 0.6, + child: StretchTrackerView.buildHeadView( + NeckStretchState.mainNeckStretch.assetPathHeadSide, + NeckStretchState.mainNeckStretch.assetPathNeckSide, + Alignment.center.add(Alignment(0, 0.3)), + -neckStretchViewModel.attitude.pitch, + 50, + NeckStretchState.mainNeckStretch), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 0), + child: RichText( + textAlign: TextAlign.justify, + text: TextSpan( + children: [ + TextSpan( + text: + 'The area of your neck that is currently being stretched will be colored in '), + TextSpan( + text: 'blue', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: stretchedAreaColor, + ), + ), + TextSpan( + text: + '.\n\nWhenever an exercise is over a sound will play to inform you that you are done with the current stretch. Afterwards you will have a small time to prepare for the next stretch (normally 5 seconds). The button will always keep you up to date with your time limits, and above the tracker is also a text telling you what exactly you are currently stretching.\n\n'), + TextSpan( + text: + 'You can use the button bellow to have a preview of how the tracking will look.\n'), + ], + ), + ), + ), + ], + ), + ), + + /// Card explaining the stretching button + Card( + color: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + ListTile( + title: Text("Explaining the Stretching Button"), + ), + + /// Explainer text for the button + Padding( + padding: EdgeInsets.all(16), + child: RichText( + textAlign: TextAlign.justify, + text: TextSpan( + children: [ + TextSpan( + text: + 'This button is used to start the meditation or to stop it preemptively. Depending on the color you can tell the current state:\n', + ), + ], + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 0), + child: RichText( + textAlign: TextAlign.justify, + text: TextSpan( + children: [ + TextSpan( + text: 'Green: ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: startButtonColor, + ), + ), + TextSpan( + text: 'You are currently not meditating\n\n', + ), + TextSpan( + text: 'Red: ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: stopButtonColor, + ), + ), + TextSpan( + text: + 'You are currently meditating, the button will display the remaining time\n\n', + ), + TextSpan( + text: 'Yellow: ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: restingButtonColor, + ), + ), + TextSpan( + text: + 'You are currently having a break between the stretches. The button displays the remaining time.\n\n'), + ], + ), + ), + ), + + this._buildMeditationButton(neckStretchViewModel), + ], + ), + ), + ], + ); + } + + // Creates the Button used to start the stretch exercise + Widget _buildMeditationButton(StretchViewModel neckStretchViewModel) { + return Padding( + padding: EdgeInsets.all(5), + child: Column(children: [ + ElevatedButton( + onPressed: neckStretchViewModel.isAvailable + ? () { + neckStretchViewModel.isTracking + ? neckStretchViewModel.stopTracking() + : neckStretchViewModel.startTracking(); + } + : null, + style: ElevatedButton.styleFrom( + backgroundColor: !neckStretchViewModel.isTracking + ? startButtonColor + : stopButtonColor, + foregroundColor: Colors.black, + ), + child: neckStretchViewModel.isTracking + ? const Text("Stop Stretching") + : const Text("Start Stretching"), + ), + ]), + ); + } +} diff --git a/open_earable/lib/apps_tab/neck_stretch/view_model/stretch_view_model.dart b/open_earable/lib/apps_tab/neck_stretch/view_model/stretch_view_model.dart new file mode 100644 index 0000000..7b49e9e --- /dev/null +++ b/open_earable/lib/apps_tab/neck_stretch/view_model/stretch_view_model.dart @@ -0,0 +1,137 @@ +import "dart:core"; +import "dart:async"; +import "package:flutter/material.dart"; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude_tracker.dart'; +import 'package:open_earable/apps_tab/neck_stretch/model/stretch_state.dart'; +import 'package:open_earable_flutter/src/open_earable_flutter.dart'; + +class StretchViewModel extends ChangeNotifier { + Attitude _attitude = Attitude(); + + /// Getters for the attitude-Tracker + Attitude get attitude => _attitude; + + bool get isTracking => _attitudeTracker.isTracking; + + bool get isAvailable => _attitudeTracker.isAvailable; + + /// Getters for the neck stretch settings, state and stats + NeckStretch get neckStretch => _neckStretch; + + StretchSettings get stretchSettings => _neckStretch.settings; + + NeckStretchState get stretchState => _neckStretch.settings.state; + + Duration get restDuration => _neckStretch.restDuration; + + bool get isResting => _neckStretch.resting; + + StretchStats get stretchStats => _stretchStats; + + /// Setter for the neck stretching settings + set stretchSettings(StretchSettings settings) => + _neckStretch.settings = settings; + + AttitudeTracker _attitudeTracker; + OpenEarable _openEarable; + + /// The model class containing all information and logics needed to start and handle a guided neck stretch + late NeckStretch _neckStretch; + late StretchStats _stretchStats; + + /// Timer that is used to track the current stretching stats, called every 0.01s + late Timer _settingsTracker; + + StretchViewModel(this._attitudeTracker, this._openEarable) { + _attitudeTracker.didChangeAvailability = (_) { + notifyListeners(); + }; + + this._neckStretch = NeckStretch(_openEarable, this); + this._stretchStats = StretchStats(); + + _attitudeTracker.listen((attitude) { + _attitude = Attitude( + roll: attitude.roll, pitch: attitude.pitch, yaw: attitude.yaw); + notifyListeners(); + }); + } + + /// Starts tracking of using OpenEarable + void startTracking() { + _attitudeTracker.start(); + _stretchStats.clear(); + _settingsTracker = Timer.periodic(new Duration(milliseconds: 10), (timer) { + _trackStretchStats(); + }); + notifyListeners(); + } + + /// Stops tracking of the OpenEarable and resets the attitude for the headViews + void stopTracking() { + _attitudeTracker.stop(); + _attitude = Attitude(); + _settingsTracker.cancel(); + notifyListeners(); + } + + /// Used to calibrate the starting point for the head tracking + void calibrate() { + _attitudeTracker.calibrateToCurrentAttitude(); + } + + @override + void dispose() { + _attitudeTracker.cancel(); + super.dispose(); + } + + /// Track the stretch stats according to current stretch state + void _trackStretchStats() { + /// If you are resting, don't track, only last refresh + if (this.isResting) { + return; + } + const toAngle = 180 / 3.14; + switch (_neckStretch.settings.state) { + case NeckStretchState.mainNeckStretch: + _stretchStats.maxMainAngle = + _attitude.pitch > _stretchStats.maxMainAngle + ? _attitude.pitch + : _stretchStats.maxMainAngle; + + /// Sets the stretch duration + if ((_attitude.pitch * toAngle) >= + _neckStretch.settings.forwardStretchAngle) { + _stretchStats.mainStretchDuration += 0.01; + } + return; + case NeckStretchState.rightNeckStretch: + _stretchStats.maxRightAngle = + -_attitude.roll > _stretchStats.maxRightAngle + ? -_attitude.roll + : _stretchStats.maxRightAngle; + + /// Sets the stretch duration + if ((-_attitude.roll * toAngle) >= + _neckStretch.settings.sideStretchAngle) { + _stretchStats.rightStretchDuration += 0.01; + } + return; + case NeckStretchState.leftNeckStretch: + _stretchStats.maxLeftAngle = _attitude.roll > _stretchStats.maxLeftAngle + ? _attitude.roll + : _stretchStats.maxLeftAngle; + + /// Sets the stretch duration + if (_attitude.roll * toAngle >= + _neckStretch.settings.sideStretchAngle) { + _stretchStats.leftStretchDuration += 0.01; + } + return; + default: + return; + } + } +} diff --git a/open_earable/lib/apps_tab/posture_tracker/assets/Head_Front.png b/open_earable/lib/apps_tab/posture_tracker/assets/Head_Front.png new file mode 100644 index 0000000..80387d7 Binary files /dev/null and b/open_earable/lib/apps_tab/posture_tracker/assets/Head_Front.png differ diff --git a/open_earable/lib/apps_tab/posture_tracker/assets/Head_Side.png b/open_earable/lib/apps_tab/posture_tracker/assets/Head_Side.png new file mode 100644 index 0000000..41817ab Binary files /dev/null and b/open_earable/lib/apps_tab/posture_tracker/assets/Head_Side.png differ diff --git a/open_earable/lib/apps_tab/posture_tracker/assets/Neck_Front.png b/open_earable/lib/apps_tab/posture_tracker/assets/Neck_Front.png new file mode 100644 index 0000000..b4648ce Binary files /dev/null and b/open_earable/lib/apps_tab/posture_tracker/assets/Neck_Front.png differ diff --git a/open_earable/lib/apps_tab/posture_tracker/assets/Neck_Side.png b/open_earable/lib/apps_tab/posture_tracker/assets/Neck_Side.png new file mode 100644 index 0000000..ff09617 Binary files /dev/null and b/open_earable/lib/apps_tab/posture_tracker/assets/Neck_Side.png differ diff --git a/open_earable/lib/apps_tab/posture_tracker/assets/logo.png b/open_earable/lib/apps_tab/posture_tracker/assets/logo.png new file mode 100644 index 0000000..bd88af7 Binary files /dev/null and b/open_earable/lib/apps_tab/posture_tracker/assets/logo.png differ diff --git a/open_earable/lib/apps/posture_tracker/model/attitude.dart b/open_earable/lib/apps_tab/posture_tracker/model/attitude.dart similarity index 57% rename from open_earable/lib/apps/posture_tracker/model/attitude.dart rename to open_earable/lib/apps_tab/posture_tracker/model/attitude.dart index 911bdfd..68490a7 100644 --- a/open_earable/lib/apps/posture_tracker/model/attitude.dart +++ b/open_earable/lib/apps_tab/posture_tracker/model/attitude.dart @@ -1,8 +1,10 @@ class Attitude { /// the roll axis attitude in radians double roll; + /// the pitch axis attitude in radians double pitch; + /// the yaw axis attitude in radians double yaw; @@ -10,33 +12,25 @@ class Attitude { Attitude operator +(Attitude other) { return Attitude( - roll: roll + other.roll, - pitch: pitch + other.pitch, - yaw: yaw + other.yaw - ); + roll: roll + other.roll, + pitch: pitch + other.pitch, + yaw: yaw + other.yaw); } Attitude operator -(Attitude other) { return Attitude( - roll: roll - other.roll, - pitch: pitch - other.pitch, - yaw: yaw - other.yaw - ); + roll: roll - other.roll, + pitch: pitch - other.pitch, + yaw: yaw - other.yaw); } Attitude operator *(double scalar) { return Attitude( - roll: roll * scalar, - pitch: pitch * scalar, - yaw: yaw * scalar - ); + roll: roll * scalar, pitch: pitch * scalar, yaw: yaw * scalar); } Attitude operator /(double scalar) { return Attitude( - roll: roll / scalar, - pitch: pitch / scalar, - yaw: yaw / scalar - ); + roll: roll / scalar, pitch: pitch / scalar, yaw: yaw / scalar); } -} \ No newline at end of file +} diff --git a/open_earable/lib/apps/posture_tracker/model/attitude_tracker.dart b/open_earable/lib/apps_tab/posture_tracker/model/attitude_tracker.dart similarity index 77% rename from open_earable/lib/apps/posture_tracker/model/attitude_tracker.dart rename to open_earable/lib/apps_tab/posture_tracker/model/attitude_tracker.dart index 2ccf5c1..13cc174 100644 --- a/open_earable/lib/apps/posture_tracker/model/attitude_tracker.dart +++ b/open_earable/lib/apps_tab/posture_tracker/model/attitude_tracker.dart @@ -8,35 +8,37 @@ import 'attitude.dart'; /// An abstract class for attitude trackers. abstract class AttitudeTracker { - StreamController _attitudeStreamController = StreamController.broadcast(); + StreamController _attitudeStreamController = + StreamController.broadcast(); Attitude _rawAttitude = Attitude(); Attitude get rawAttitude => _rawAttitude; Attitude _attitude = Attitude(); Attitude get attitude => _attitude; bool get isTracking; + /// check if tracking is available bool get isAvailable => true; Attitude _referenceAttitude = Attitude(); /// Callback that is called when the tracker changes availability. Takes the tracker as an argument. - Function(AttitudeTracker) didChangeAvailability = (_) { }; + Function(AttitudeTracker) didChangeAvailability = (_) {}; /// Listen to the attitude stream. - /// + /// /// [callback] is called when a new attitude is received. void listen(void Function(Attitude) callback) { this._attitudeStreamController.stream.listen(callback); } /// Start tracking the attitude. - /// + /// /// In order to receive the data, you need to call `listen()` first. void start(); /// Stop tracking the attitude. - /// + /// /// You can resume the tracking by calling `start()` again. void stop(); @@ -46,20 +48,23 @@ abstract class AttitudeTracker { void calibrateToCurrentAttitude() async { _referenceAttitude = _rawAttitude; - print("calibrated to {roll: ${_referenceAttitude.roll}, pitch: ${_referenceAttitude.pitch}, yaw: ${_referenceAttitude.yaw}}"); + print( + "calibrated to {roll: ${_referenceAttitude.roll}, pitch: ${_referenceAttitude.pitch}, yaw: ${_referenceAttitude.yaw}}"); } /// Cancle the stream and close the stream controller. - /// + /// /// If you want to use the tracker again, you need to call listen() again. @mustCallSuper - void cancle() { + void cancel() { this._attitudeStreamController.close(); } - void updateAttitude ({double? roll, double? pitch, double? yaw, Attitude? attitude}) { + void updateAttitude( + {double? roll, double? pitch, double? yaw, Attitude? attitude}) { if (roll == null && pitch == null && yaw == null && attitude == null) { - throw ArgumentError("Either roll, pitch and yaw or attitude must be provided"); + throw ArgumentError( + "Either roll, pitch and yaw or attitude must be provided"); } // Check if attitude is not null, otherwise use the angles attitude ??= Attitude(roll: roll ?? 0, pitch: pitch ?? 0, yaw: yaw ?? 0); @@ -68,5 +73,4 @@ abstract class AttitudeTracker { _attitude = attitude - _referenceAttitude; _attitudeStreamController.add(_attitude); } - -} \ No newline at end of file +} diff --git a/open_earable/lib/apps/posture_tracker/model/bad_posture_reminder.dart b/open_earable/lib/apps_tab/posture_tracker/model/bad_posture_reminder.dart similarity index 72% rename from open_earable/lib/apps/posture_tracker/model/bad_posture_reminder.dart rename to open_earable/lib/apps_tab/posture_tracker/model/bad_posture_reminder.dart index d494d04..6ef0d5b 100644 --- a/open_earable/lib/apps/posture_tracker/model/bad_posture_reminder.dart +++ b/open_earable/lib/apps_tab/posture_tracker/model/bad_posture_reminder.dart @@ -1,10 +1,9 @@ import 'dart:math'; -import 'package:open_earable/apps/posture_tracker/model/attitude.dart'; -import 'package:open_earable/apps/posture_tracker/model/attitude_tracker.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude_tracker.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; - class BadPostureSettings { bool isActive; @@ -20,28 +19,26 @@ class BadPostureSettings { /// The time threshold in seconds for resetting the timer int resetTimeThreshold; - BadPostureSettings({ - this.isActive = true, - required this.rollAngleThreshold, - required this.pitchAngleThreshold, - required this.timeThreshold, - required this.resetTimeThreshold - }); + BadPostureSettings( + {this.isActive = true, + required this.rollAngleThreshold, + required this.pitchAngleThreshold, + required this.timeThreshold, + required this.resetTimeThreshold}); } class PostureTimestamps { DateTime? lastBadPosture; DateTime? lastGoodPosture; - DateTime lastReset =DateTime.now(); + DateTime lastReset = DateTime.now(); } class BadPostureReminder { BadPostureSettings _settings = BadPostureSettings( - rollAngleThreshold: 20, - pitchAngleThreshold: 20, - timeThreshold: 10, - resetTimeThreshold: 1 - ); + rollAngleThreshold: 20, + pitchAngleThreshold: 20, + timeThreshold: 10, + resetTimeThreshold: 1); final OpenEarable _openEarable; final AttitudeTracker _attitudeTracker; PostureTimestamps _timestamps = PostureTimestamps(); @@ -66,7 +63,8 @@ class BadPostureReminder { DateTime now = DateTime.now(); if (_isBadPosture(attitude)) { - if (!(_lastPostureWasBad ?? false)) { + if (!(_lastPostureWasBad ?? false) && + _openEarable.bleManager.connected) { _openEarable.rgbLed.writeLedColor(r: 255, g: 0, b: 0); } @@ -88,7 +86,9 @@ class BadPostureReminder { _timestamps.lastGoodPosture = null; } else { if (_lastPostureWasBad ?? false) { - _openEarable.rgbLed.writeLedColor(r: 0, g: 255, b: 0); + if (_openEarable.bleManager.connected) { + _openEarable.rgbLed.writeLedColor(r: 0, g: 255, b: 0); + } } // If this is the first time the program enters the good state, store the current time @@ -101,7 +101,8 @@ class BadPostureReminder { int duration = now.difference(_timestamps.lastGoodPosture!).inSeconds; // If the duration exceeds the minimum required, reset the last bad state time if (duration >= _settings.resetTimeThreshold) { - print("duration: $duration, reset time threshold: ${_settings.resetTimeThreshold}"); + print( + "duration: $duration, reset time threshold: ${_settings.resetTimeThreshold}"); print("resetting last bad posture time"); _timestamps.lastBadPosture = null; } @@ -114,21 +115,27 @@ class BadPostureReminder { void stop() { _timestamps.lastBadPosture = null; _timestamps.lastGoodPosture = null; - _openEarable.rgbLed.writeLedColor(r: 0, g: 0, b: 0); + if (_openEarable.bleManager.connected) { + _openEarable.rgbLed.writeLedColor(r: 0, g: 0, b: 0); + } _attitudeTracker.stop(); } - + void setSettings(BadPostureSettings settings) { _settings = settings; } bool _isBadPosture(Attitude attitude) { - return attitude.roll.abs() * (360 / (2 * pi)) > _settings.rollAngleThreshold || attitude.pitch.abs() * (360 / (2 * pi)) > _settings.pitchAngleThreshold; + return attitude.roll.abs() * (360 / (2 * pi)) > + _settings.rollAngleThreshold || + attitude.pitch.abs() * (360 / (2 * pi)) > _settings.pitchAngleThreshold; } void alarm() { print("playing jingle to alert of bad posture"); // play jingle - _openEarable.audioPlayer.jingle(4); + if (_openEarable.bleManager.connected) { + _openEarable.audioPlayer.jingle(4); + } } -} \ No newline at end of file +} diff --git a/open_earable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart b/open_earable/lib/apps_tab/posture_tracker/model/earable_attitude_tracker.dart similarity index 65% rename from open_earable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart rename to open_earable/lib/apps_tab/posture_tracker/model/earable_attitude_tracker.dart index 0e151e4..8fd8627 100644 --- a/open_earable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart +++ b/open_earable/lib/apps_tab/posture_tracker/model/earable_attitude_tracker.dart @@ -1,13 +1,13 @@ import 'dart:async'; -import 'package:open_earable/apps/posture_tracker/model/attitude_tracker.dart'; -import 'package:open_earable/apps/posture_tracker/model/ewma.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude_tracker.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/ewma.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; class EarableAttitudeTracker extends AttitudeTracker { final OpenEarable _openEarable; StreamSubscription>? _subscription; - + @override bool get isTracking => _subscription != null && !_subscription!.isPaused; @override @@ -21,7 +21,7 @@ class EarableAttitudeTracker extends AttitudeTracker { _openEarable.bleManager.connectionStateStream.listen((connected) { didChangeAvailability(this); if (!connected) { - cancle(); + cancel(); } }); } @@ -34,12 +34,12 @@ class EarableAttitudeTracker extends AttitudeTracker { } _openEarable.sensorManager.writeSensorConfig(_buildSensorConfig()); - _subscription = _openEarable.sensorManager.subscribeToSensorData(0).listen((event) { + _subscription = + _openEarable.sensorManager.subscribeToSensorData(0).listen((event) { updateAttitude( - roll: _rollEWMA.update(event["EULER"]["ROLL"]), - pitch: _pitchEWMA.update(event["EULER"]["PITCH"]), - yaw: _yawEWMA.update(event["EULER"]["YAW"]) - ); + roll: _rollEWMA.update(event["EULER"]["ROLL"]), + pitch: _pitchEWMA.update(event["EULER"]["PITCH"]), + yaw: _yawEWMA.update(event["EULER"]["YAW"])); }); } @@ -49,17 +49,13 @@ class EarableAttitudeTracker extends AttitudeTracker { } @override - void cancle() { + void cancel() { stop(); _subscription?.cancel(); - super.cancle(); + super.cancel(); } OpenEarableSensorConfig _buildSensorConfig() { - return OpenEarableSensorConfig( - sensorId: 0, - samplingRate: 30, - latency: 0 - ); + return OpenEarableSensorConfig(sensorId: 0, samplingRate: 30, latency: 0); } -} \ No newline at end of file +} diff --git a/open_earable/lib/apps/posture_tracker/model/ewma.dart b/open_earable/lib/apps_tab/posture_tracker/model/ewma.dart similarity index 99% rename from open_earable/lib/apps/posture_tracker/model/ewma.dart rename to open_earable/lib/apps_tab/posture_tracker/model/ewma.dart index da2d62f..d013478 100644 --- a/open_earable/lib/apps/posture_tracker/model/ewma.dart +++ b/open_earable/lib/apps_tab/posture_tracker/model/ewma.dart @@ -8,4 +8,4 @@ class EWMA { _oldValue = _alpha * newValue + (1 - _alpha) * _oldValue; return _oldValue; } -} \ No newline at end of file +} diff --git a/open_earable/lib/apps/posture_tracker/model/mock_attitude_tracker.dart b/open_earable/lib/apps_tab/posture_tracker/model/mock_attitude_tracker.dart similarity index 68% rename from open_earable/lib/apps/posture_tracker/model/mock_attitude_tracker.dart rename to open_earable/lib/apps_tab/posture_tracker/model/mock_attitude_tracker.dart index bfca7e8..6b0600e 100644 --- a/open_earable/lib/apps/posture_tracker/model/mock_attitude_tracker.dart +++ b/open_earable/lib/apps_tab/posture_tracker/model/mock_attitude_tracker.dart @@ -1,29 +1,30 @@ import 'dart:async'; import 'dart:math'; -import 'package:open_earable/apps/posture_tracker/model/attitude.dart'; -import 'package:open_earable/apps/posture_tracker/model/attitude_tracker.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude_tracker.dart'; class MockAttitudeTracker extends AttitudeTracker { Stream _attitudeStream = Stream.empty(); StreamSubscription? _attitudeSubscription; @override - bool get isTracking => _attitudeSubscription != null && !_attitudeSubscription!.isPaused; + bool get isTracking => + _attitudeSubscription != null && !_attitudeSubscription!.isPaused; bool _isAvailable = false; @override bool get isAvailable => _isAvailable; - MockAttitudeTracker({Function(AttitudeTracker)? didChangeAvailability}) : super() { + MockAttitudeTracker({Function(AttitudeTracker)? didChangeAvailability}) + : super() { _attitudeStream = Stream.periodic(Duration(milliseconds: 100), (count) { return Attitude( - roll: sin(count / 10) * pi / 4, - pitch: sin(count / 20) * pi / 4, - yaw: sin(count / 30) * pi / 4 - ); + roll: sin(count / 10) * pi / 4, + pitch: sin(count / 20) * pi / 4, + yaw: sin(count / 30) * pi / 4); }); - didChangeAvailability = didChangeAvailability ?? (_) { }; + didChangeAvailability = didChangeAvailability ?? (_) {}; didChangeAvailability(this); // wait for 5 seconds before setting the tracker to available @@ -53,8 +54,8 @@ class MockAttitudeTracker extends AttitudeTracker { } @override - void cancle() { + void cancel() { _attitudeSubscription?.cancel(); - super.cancle(); + super.cancel(); } -} \ No newline at end of file +} diff --git a/open_earable/lib/apps/posture_tracker/view/arc_painter.dart b/open_earable/lib/apps_tab/posture_tracker/view/arc_painter.dart similarity index 100% rename from open_earable/lib/apps/posture_tracker/view/arc_painter.dart rename to open_earable/lib/apps_tab/posture_tracker/view/arc_painter.dart diff --git a/open_earable/lib/apps/posture_tracker/view/posture_roll_view.dart b/open_earable/lib/apps_tab/posture_tracker/view/posture_roll_view.dart similarity index 96% rename from open_earable/lib/apps/posture_tracker/view/posture_roll_view.dart rename to open_earable/lib/apps_tab/posture_tracker/view/posture_roll_view.dart index 1bab1cd..51d77ec 100644 --- a/open_earable/lib/apps/posture_tracker/view/posture_roll_view.dart +++ b/open_earable/lib/apps_tab/posture_tracker/view/posture_roll_view.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:open_earable/apps/posture_tracker/view/arc_painter.dart'; +import 'package:open_earable/apps_tab/posture_tracker/view/arc_painter.dart'; /// A widget that displays the roll of the head and neck. class PostureRollView extends StatelessWidget { diff --git a/open_earable/lib/apps/posture_tracker/view/posture_tracker_view.dart b/open_earable/lib/apps_tab/posture_tracker/view/posture_tracker_view.dart similarity index 85% rename from open_earable/lib/apps/posture_tracker/view/posture_tracker_view.dart rename to open_earable/lib/apps_tab/posture_tracker/view/posture_tracker_view.dart index d66b0bc..18e5e0b 100644 --- a/open_earable/lib/apps/posture_tracker/view/posture_tracker_view.dart +++ b/open_earable/lib/apps_tab/posture_tracker/view/posture_tracker_view.dart @@ -1,11 +1,11 @@ // ignore_for_file: unnecessary_this import 'package:flutter/material.dart'; -import 'package:open_earable/apps/posture_tracker/model/attitude_tracker.dart'; -import 'package:open_earable/apps/posture_tracker/model/bad_posture_reminder.dart'; -import 'package:open_earable/apps/posture_tracker/view/posture_roll_view.dart'; -import 'package:open_earable/apps/posture_tracker/view/settings_view.dart'; -import 'package:open_earable/apps/posture_tracker/view_model/posture_tracker_view_model.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude_tracker.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/bad_posture_reminder.dart'; +import 'package:open_earable/apps_tab/posture_tracker/view/posture_roll_view.dart'; +import 'package:open_earable/apps_tab/posture_tracker/view/settings_view.dart'; +import 'package:open_earable/apps_tab/posture_tracker/view_model/posture_tracker_view_model.dart'; import 'package:provider/provider.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; @@ -74,15 +74,15 @@ class _PostureTrackerViewState extends State { List _createHeadViews(postureTrackerViewModel) { return [ this._buildHeadView( - "assets/posture_tracker/Head_Front.png", - "assets/posture_tracker/Neck_Front.png", + "lib/apps_tab/posture_tracker/assets/Head_Front.png", + "lib/apps_tab/posture_tracker/assets/Neck_Front.png", Alignment.center.add(Alignment(0, 0.3)), postureTrackerViewModel.attitude.roll, postureTrackerViewModel.badPostureSettings.rollAngleThreshold .toDouble()), this._buildHeadView( - "assets/posture_tracker/Head_Side.png", - "assets/posture_tracker/Neck_Side.png", + "lib/apps_tab/posture_tracker/assets/Head_Side.png", + "lib/apps_tab/posture_tracker/assets/Neck_Side.png", Alignment.center.add(Alignment(0, 0.3)), -postureTrackerViewModel.attitude.pitch, postureTrackerViewModel.badPostureSettings.pitchAngleThreshold diff --git a/open_earable/lib/apps/posture_tracker/view/settings_view.dart b/open_earable/lib/apps_tab/posture_tracker/view/settings_view.dart similarity index 98% rename from open_earable/lib/apps/posture_tracker/view/settings_view.dart rename to open_earable/lib/apps_tab/posture_tracker/view/settings_view.dart index 9076399..53993bb 100644 --- a/open_earable/lib/apps/posture_tracker/view/settings_view.dart +++ b/open_earable/lib/apps_tab/posture_tracker/view/settings_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:open_earable/apps/posture_tracker/model/bad_posture_reminder.dart'; -import 'package:open_earable/apps/posture_tracker/view_model/posture_tracker_view_model.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/bad_posture_reminder.dart'; +import 'package:open_earable/apps_tab/posture_tracker/view_model/posture_tracker_view_model.dart'; import 'package:provider/provider.dart'; class SettingsView extends StatefulWidget { diff --git a/open_earable/lib/apps/posture_tracker/view_model/posture_tracker_view_model.dart b/open_earable/lib/apps_tab/posture_tracker/view_model/posture_tracker_view_model.dart similarity index 66% rename from open_earable/lib/apps/posture_tracker/view_model/posture_tracker_view_model.dart rename to open_earable/lib/apps_tab/posture_tracker/view_model/posture_tracker_view_model.dart index 0f07bc2..f03a927 100644 --- a/open_earable/lib/apps/posture_tracker/view_model/posture_tracker_view_model.dart +++ b/open_earable/lib/apps_tab/posture_tracker/view_model/posture_tracker_view_model.dart @@ -1,7 +1,7 @@ import "package:flutter/material.dart"; -import "package:open_earable/apps/posture_tracker/model/attitude.dart"; -import 'package:open_earable/apps/posture_tracker/model/attitude_tracker.dart'; -import "package:open_earable/apps/posture_tracker/model/bad_posture_reminder.dart"; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/attitude_tracker.dart'; +import 'package:open_earable/apps_tab/posture_tracker/model/bad_posture_reminder.dart'; class PostureTrackerViewModel extends ChangeNotifier { Attitude _attitude = Attitude(); @@ -14,32 +14,37 @@ class PostureTrackerViewModel extends ChangeNotifier { AttitudeTracker _attitudeTracker; BadPostureReminder _badPostureReminder; - + bool _isDisposed = false; PostureTrackerViewModel(this._attitudeTracker, this._badPostureReminder) { _attitudeTracker.didChangeAvailability = (_) { - notifyListeners(); + if (!_isDisposed) { + notifyListeners(); + } }; _attitudeTracker.listen((attitude) { _attitude = Attitude( - roll: attitude.roll, - pitch: attitude.pitch, - yaw: attitude.yaw - ); - notifyListeners(); + roll: attitude.roll, pitch: attitude.pitch, yaw: attitude.yaw); + if (!_isDisposed) { + notifyListeners(); + } }); } void startTracking() { _attitudeTracker.start(); _badPostureReminder.start(); - notifyListeners(); + if (!_isDisposed) { + notifyListeners(); + } } void stopTracking() { _attitudeTracker.stop(); _badPostureReminder.stop(); - notifyListeners(); + if (!_isDisposed) { + notifyListeners(); + } } void calibrate() { @@ -53,7 +58,8 @@ class PostureTrackerViewModel extends ChangeNotifier { @override void dispose() { stopTracking(); - _attitudeTracker.cancle(); + _attitudeTracker.cancel(); + _isDisposed = true; super.dispose(); } -} \ No newline at end of file +} diff --git a/open_earable/lib/apps_tab/powernapper/assets/logo.png b/open_earable/lib/apps_tab/powernapper/assets/logo.png new file mode 100644 index 0000000..edc28f8 Binary files /dev/null and b/open_earable/lib/apps_tab/powernapper/assets/logo.png differ diff --git a/open_earable/lib/apps_tab/powernapper/assets/powernapping.png b/open_earable/lib/apps_tab/powernapper/assets/powernapping.png new file mode 100644 index 0000000..b9e2514 Binary files /dev/null and b/open_earable/lib/apps_tab/powernapper/assets/powernapping.png differ diff --git a/open_earable/lib/apps_tab/powernapper/home_screen.dart b/open_earable/lib/apps_tab/powernapper/home_screen.dart new file mode 100644 index 0000000..8760d41 --- /dev/null +++ b/open_earable/lib/apps_tab/powernapper/home_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:open_earable/apps_tab/powernapper/timerscreen.dart'; +import 'package:open_earable_flutter/src/open_earable_flutter.dart'; + +import 'interact.dart'; + +/// Homescreen class for the movement timer application. +class SleepHomeScreen extends StatefulWidget { + final OpenEarable _openEarable; + SleepHomeScreen(this._openEarable); + @override + _HomeScreenState createState() => _HomeScreenState(_openEarable); +} + +/// State for the HomeScreenApplication. +/// +/// Needs the [OpenEarable]-Object to interact. +class _HomeScreenState extends State { + final OpenEarable _openEarable; + + //Constructor + _HomeScreenState(this._openEarable); + + //Bottom-Navigation-Bar index. + int _currentIndex = 0; + + //Build main Widget. + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.background, + title: Text('Powernapper Alarm Clock'), + ), + + //Body for the widget + body: _getBody(), + + //Bottom Navigation Bar + bottomNavigationBar: BottomNavigationBar( + backgroundColor: Theme.of(context).colorScheme.background, + currentIndex: _currentIndex, + onTap: _onNavBarItemTapped, + items: [ + BottomNavigationBarItem( + icon: Icon(Icons.timer), + label: 'Timer', + ), + BottomNavigationBarItem( + icon: Icon(Icons.info), + label: 'Info', + ), + ], + ), + ); + } + + ///Body-Widget for Main Widget. + Widget _getBody() { + switch (_currentIndex) { + case 0: + + //Timer Tab + return TimerScreen(Interact(_openEarable)); + case 1: + + //Information Tab + return Column( + children: [ + Spacer(), + Padding( + padding: const EdgeInsets.all(18.0), + child: Text( + 'Mit der Powernapping App können Sie einen Timer starten, der ganz ' + 'automatisch an Ihren Bewegungen erkennt, wann Sie wirklich eingeschlafen sind. ' + 'Der Timer wird automatisch restartet, wenn Sie sich bewegen, ' + 'so können Sie effektiv powernappen und eine gemütliche Position finden ' + 'ohne dass schon die Zeit abläuft!', + style: TextStyle(fontSize: 24), + ), + ), + Spacer(), + Padding( + padding: const EdgeInsets.all(18.0), + child: Text( + 'Diese Sub-App wurde entwickelt von: Philipp Ochs, Matrikelnummer 2284828', + style: TextStyle(fontSize: 16), + ), + ), + ], + ); + + default: + + //Default + return Center( + child: Text('Ungültiger Index'), + ); + } + } + + ///Navigation-Bar interaction + /// + /// [Index] is the tab index activated. + void _onNavBarItemTapped(int index) { + setState(() { + _currentIndex = index; + }); + } +} diff --git a/open_earable/lib/apps_tab/powernapper/interact.dart b/open_earable/lib/apps_tab/powernapper/interact.dart new file mode 100644 index 0000000..cb3c528 --- /dev/null +++ b/open_earable/lib/apps_tab/powernapper/interact.dart @@ -0,0 +1,24 @@ +import 'package:open_earable_flutter/src/open_earable_flutter.dart'; + +///Interaction class for the earable. All actions executed on the earable are accessible through this class. +///For example rings or led colors. +class Interact { + final OpenEarable _openEarable; + + //Constructor + Interact(this._openEarable); + + //Getter for the Earable + OpenEarable getEarable() { + return _openEarable; + } + + ///Lets the OpenEarable play the jingel-ID: '1'. + void ring() { + try { + _openEarable.audioPlayer.jingle(1); + } catch (e) { + print('ERROR: Jingle konnte nicht gespielt werden!'); + } + } +} diff --git a/open_earable/lib/apps_tab/powernapper/movementTracker.dart b/open_earable/lib/apps_tab/powernapper/movementTracker.dart new file mode 100644 index 0000000..023d2c4 --- /dev/null +++ b/open_earable/lib/apps_tab/powernapper/movementTracker.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:open_earable/apps_tab/powernapper/interact.dart'; +import 'package:open_earable/apps_tab/powernapper/sensor_datatypes.dart'; +import 'package:open_earable_flutter/src/open_earable_flutter.dart'; + +/// Movement Tracker has lgoic for timer & movement validation. +class MovementTracker { + //Incetaction variables + final Interact _interact; + late final OpenEarable _openEarable; + + Timer? _timer; + + //Stream Subscription + StreamSubscription>? _subscription; + + //Constructor + MovementTracker(this._interact) { + this._openEarable = _interact.getEarable(); + } + + ///Start Subscription and reset timer. + /// + /// Input: [minutes] for the time before the ring. + /// Input: [updateText] as an void callback function for the textupdate. + void start(int minutes, void Function(SensorDataType s) updateText) { + //Timer (re-)start + stop(); + _startTimer(minutes); + + //Sets sensor config + _openEarable.sensorManager.writeSensorConfig(_buildSensorConfig()); + + //Starts listening to the subscription + _subscription = + _openEarable.sensorManager.subscribeToSensorData(0).listen((event) { + //Display update callback + updateText(Gyroscope(event)); + + //Timer update + _update(Gyroscope(event), minutes); + }); + } + + ///(Re-)Starts timer and cancels subscription & calls ring() when finished. + /// + /// Input: int [minutes] for the timer length. + void _startTimer(int minutes) { + _timer?.cancel(); + _timer = Timer(Duration(minutes: minutes), () { + //End of timer: + _interact.ring(); + stop(); + }); + } + + /// Cancels timer and subscription to the Earable sensor stream. + void stop() { + _timer?.cancel(); + _subscription?.cancel(); + } + + /// Update method for restarting timer when movement is tracked. + /// + /// Uses the [SensorDataType] to validate update and int [minutes] to restart the timer. + void _update(SensorDataType dt, int minutes) { + if (_validMovement(dt)) { + _timer?.cancel(); + _startTimer(minutes); + } + } + + /// Validates wether the given sensordata could be interpretet as a movement. + /// + /// Input: [SensorDataType] with the data to be validated. + bool _validMovement(SensorDataType dt) { + Gyroscope gyro; + + if (dt is Gyroscope) { + gyro = dt; + + //Threshold validating for gyroscope data. + if (gyro.x.abs() > 5 || gyro.y.abs() > 5 || gyro.z.abs() > 5) { + return true; + } + } + return false; + } + + ///Sensor Config for the earable. + OpenEarableSensorConfig _buildSensorConfig() { + return OpenEarableSensorConfig(sensorId: 0, samplingRate: 30, latency: 0); + } +} diff --git a/open_earable/lib/apps_tab/powernapper/sensor_datatypes.dart b/open_earable/lib/apps_tab/powernapper/sensor_datatypes.dart new file mode 100644 index 0000000..53a9db1 --- /dev/null +++ b/open_earable/lib/apps_tab/powernapper/sensor_datatypes.dart @@ -0,0 +1,37 @@ +/// Sensor Data is the super data typ for possible datatypes used for the movement tracker. +/// It reads the JSON of the Earable and provides the getters for accessing the data. +class SensorDataType { + //Data map + Map data; + + //Constructor + SensorDataType(this.data); + + //Getters for the data + double get x => data["X"]; + double get y => data["Y"]; + double get z => data["Z"]; + + //Units for the given data. + Map get units => data["units"]; +} + +/// Acceleration-sensor data. +class Acceleration extends SensorDataType { + Acceleration(Map data) : super(data["ACC"]); +} + +/// Gyroscope-sensor data. +class Gyroscope extends SensorDataType { + Gyroscope(Map data) : super(data["GYRO"]); +} + +/// EulerAngles-sensor data. +class EulerAngles extends SensorDataType { + EulerAngles(Map data) : super(data["EULER"]); +} + +/// Placeholder data without any information in case no sensor data is available. +class NullData extends SensorDataType { + NullData() : super({"X": 0.0, "Y": 0.0, "Z": 0.0}); +} diff --git a/open_earable/lib/apps_tab/powernapper/timerscreen.dart b/open_earable/lib/apps_tab/powernapper/timerscreen.dart new file mode 100644 index 0000000..fb4296c --- /dev/null +++ b/open_earable/lib/apps_tab/powernapper/timerscreen.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:open_earable/apps_tab/powernapper/movementTracker.dart'; +import 'package:open_earable/apps_tab/powernapper/sensor_datatypes.dart'; +import 'package:open_earable/ble/ble_controller.dart'; +import 'package:open_earable/shared/earable_not_connected_warning.dart'; +import 'package:provider/provider.dart'; + +import 'interact.dart'; + +/// TimerScreen - Main screen for the movment timer interaction. +class TimerScreen extends StatefulWidget { + final Interact interact; + TimerScreen(this.interact); + @override + State createState() => TimerScreenState(interact); +} + +/// State for the movement Timer Interaction +class TimerScreenState extends State { + //Interaction class + final Interact _interact; + + //Movement & timer logic + late final MovementTracker _movementTracker; + + //Input Controller + final TextEditingController _controller = TextEditingController(); + + //Display Data + SensorDataType? _sensorData = NullData(); + + //Constructor + TimerScreenState(this._interact) { + this._movementTracker = MovementTracker(_interact); + } + + @override + void dispose() { + super.dispose(); + _movementTracker.stop(); + } + + //Updates the text data. + void updateText(SensorDataType sensorData) { + setState(() { + _sensorData = sensorData; + }); + } + + ///Builds the main Widget + @override + Widget build(BuildContext context) { + return Provider.of(context, listen: false).connected + ? GestureDetector( + onTap: () { + FocusScope.of(context).requestFocus(FocusNode()); + }, + child: Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'lib/apps_tab/powernapper/assets/powernapping.png', + width: 150, + height: 150, + color: Colors.grey, + ), + SizedBox(height: 20), + + // Input for Time length + Padding( + padding: EdgeInsets.all(20), + child: TextField( + controller: _controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Zeitlänge eingeben (in Minuten)', + labelStyle: TextStyle(color: Colors.grey), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.grey))), + style: TextStyle(color: Colors.white), + cursorColor: Colors.white, + ), + ), + SizedBox(height: 20), + + // Start timer button + ElevatedButton( + onPressed: () { + String input = _controller.text; + int minutes = int.tryParse(input) ?? 0; + + _movementTracker.start(minutes, updateText); + }, + child: Text('Starten'), + ), + + //Data table for the live display of the sensor data. + DataTable( + columns: [ + DataColumn(label: Text('Sensor')), + DataColumn(label: Text('Wert')), + ], + rows: [ + DataRow( + cells: [ + DataCell(Text('X')), + DataCell(Text(_sensorData!.x.toStringAsFixed(14))), + ], + ), + DataRow( + cells: [ + DataCell(Text('Y')), + DataCell(Text(_sensorData!.y.toStringAsFixed(14))), + ], + ), + DataRow( + cells: [ + DataCell(Text('Z')), + DataCell(Text(_sensorData!.z.toStringAsFixed(14))), + ], + ), + ], + ), + ], + )), + )) + : EarableNotConnectedWarning(); + } +} diff --git a/open_earable/lib/apps_tab/recorder/assets/logo.png b/open_earable/lib/apps_tab/recorder/assets/logo.png new file mode 100644 index 0000000..1f3e8d6 Binary files /dev/null and b/open_earable/lib/apps_tab/recorder/assets/logo.png differ diff --git a/open_earable/lib/apps_tab/recorder/assets/logo_old.png b/open_earable/lib/apps_tab/recorder/assets/logo_old.png new file mode 100644 index 0000000..2e977f6 Binary files /dev/null and b/open_earable/lib/apps_tab/recorder/assets/logo_old.png differ diff --git a/open_earable/lib/apps_tab/recorder/lib/recorder.dart b/open_earable/lib/apps_tab/recorder/lib/recorder.dart new file mode 100644 index 0000000..eef4b66 --- /dev/null +++ b/open_earable/lib/apps_tab/recorder/lib/recorder.dart @@ -0,0 +1,489 @@ +import 'package:flutter/material.dart'; +import 'package:open_earable/shared/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 _recordingFolders = []; + Directory? _selectedFolder; + final OpenEarable _openEarable; + bool _recording = false; + StreamSubscription? _imuSubscription; + StreamSubscription? _barometerSubscription; + _RecorderState(this._openEarable); + CsvWriter? _imuCsvWriter; + CsvWriter? _barometerCsvWriter; + late List _imuHeader; + late List _barometerHeader; + late List _labels; + late String _selectedLabel; + Timer? _timer; + Duration _duration = Duration(); + StreamSubscription? _connectionStateSubscription; + @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"; + _imuHeader = [ + "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[°]", + ]; + + _barometerHeader = [ + "time", + "sensor_baro[Pa]", + "sensor_temp[°C]", + ]; + _imuHeader.addAll( + _labels.sublist(1).map((label) => "label_OpenEarable_${label}")); + _barometerHeader.addAll( + _labels.sublist(1).map((label) => "label_OpenEarable_${label}")); + if (_openEarable.bleManager.connected) { + _setupListeners(); + } + listSubfoldersInDocumentsDirectory(); + } + + 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(); + _connectionStateSubscription?.cancel(); + } + + Future listSubfoldersInDocumentsDirectory() async { + Directory documentsDirectory = await getApplicationDocumentsDirectory(); + List subfolders = documentsDirectory.listSync(); + _recordingFolders.clear(); + for (var subfolder in subfolders) { + if (subfolder is Directory) { + _recordingFolders.add(subfolder); + } + } + _recordingFolders.sort((a, b) { + final folderAName = a.path.split("/").last; + final folderBName = b.path.split("/").last; + return -folderAName.compareTo(folderBName); + }); + + if (_selectedFolder != null && _selectedFolder!.existsSync()) { + List files = _selectedFolder!.listSync(); + var index = _recordingFolders + .indexWhere((element) => element.path == _selectedFolder!.path); + _recordingFolders.insertAll(index + 1, files); + } + + setState(() {}); + } + + _setupListeners() { + _connectionStateSubscription = + _openEarable.bleManager.connectionStateStream.listen((connected) { + setState(() { + if (!connected) { + _recording = false; + } + }); + }); + _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()); + _imuCsvWriter?.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()); + _barometerCsvWriter?.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; + }); + _imuCsvWriter?.cancelTimer(); + _barometerCsvWriter?.cancelTimer(); + _stopTimer(); + } else { + DateTime startTime = DateTime.now(); + _imuCsvWriter = + CsvWriter("imu", startTime, listSubfoldersInDocumentsDirectory); + _imuCsvWriter?.addData(_imuHeader); + _barometerCsvWriter = + CsvWriter("baro", startTime, listSubfoldersInDocumentsDirectory); + _barometerCsvWriter?.addData(_barometerHeader); + _startTimer(); + setState(() { + _recording = true; + }); + } + } + + void deleteFileSystemEntity(FileSystemEntity entity) { + if (entity.existsSync()) { + try { + entity.deleteSync(recursive: true); + } catch (e) { + print('Error deleting folder: $e'); + } + } else { + print('Folder does not exist.'); + } + if (entity is File && entity.parent.listSync().isEmpty) { + deleteFileSystemEntity(entity.parent); + } + listSubfoldersInDocumentsDirectory(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar( + title: Text('Recorder'), + ), + body: _recorderWidget()); + } + + Widget _recorderWidget() { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container( + height: 200, + child: !_openEarable.bleManager.connected + ? EarableNotConnectedWarning() + : 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, + ), + _noRecordingsWidget(), + Expanded( + child: ListView.builder( + itemCount: _recordingFolders.length, + itemBuilder: (context, index) { + return ListTile( + contentPadding: _recordingFolders[index] is File + ? EdgeInsets.fromLTRB(40, 0, 16, 0) + : null, + title: Row( + children: [ + Expanded( + child: Text( + _recordingFolders[index].path.split("/").last, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: _recordingFolders[index] is File ? 14 : null, + ), + )), + Visibility( + visible: _recordingFolders[index] is Directory, + child: Transform.rotate( + angle: + _recordingFolders[index].path == _selectedFolder?.path + ? 90 * 3.14 / 180 + : 0, + child: Icon(Icons.arrow_right), + ), + ), + ], + ), + onTap: () { + if (_recordingFolders[index].path == _selectedFolder?.path) { + _selectedFolder = null; + listSubfoldersInDocumentsDirectory(); + } else if (_recordingFolders[index] is Directory) { + Directory d = _recordingFolders[index] as Directory; + _selectedFolder = d; + listSubfoldersInDocumentsDirectory(); + } else if (_recordingFolders[index] is File) { + OpenFile.open(_recordingFolders[index].path); + } + }, + trailing: IconButton( + icon: Icon(Icons.delete, + color: (_recording && index == 0) + ? Color.fromARGB(50, 255, 255, 255) + : Colors.white), + onPressed: () { + (_recording && index == 0) + ? null + : deleteFileSystemEntity(_recordingFolders[index]); + }, + splashColor: (_recording && index == 0) + ? Colors.transparent + : Theme.of(context).splashColor, + ), + splashColor: Colors.transparent, + ); + }, + )), + ], + ); + } + + Widget _noRecordingsWidget() { + return Visibility( + visible: _recordingFolders.isEmpty, + child: Expanded( + child: 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, + ), + ), + ], + ), + ), + ], + ))); + } +} + +class CsvWriter { + List> buffer = []; + File? file; + late Timer _timer; + + CsvWriter(String prefix, DateTime startTime, + void Function() folderCreationClosure) { + if (file == null) { + _openFile(prefix, startTime, folderCreationClosure); + } + _timer = Timer.periodic(Duration(milliseconds: 250), (Timer timer) { + if (buffer.isNotEmpty) { + if (file == null) { + _openFile(prefix, startTime, folderCreationClosure); + } + writeBufferToFile(); + } + }); + } + + cancelTimer() { + _timer.cancel(); + } + + void addData(List data) { + buffer.add(data); + } + + Future _openFile(String prefix, DateTime startTime, + void Function() folderCreationClosure) async { + String formattedDate = + startTime.toUtc().toIso8601String().replaceAll(':', '_'); + formattedDate = "${formattedDate.substring(0, formattedDate.length - 4)}Z"; + String fileName = '${prefix}_recording_$formattedDate'; + String directory = (await getApplicationDocumentsDirectory()).path; + String folderPath = '$directory/$formattedDate'; + + String filePath = '$folderPath/$fileName.csv'; + Directory folder = Directory(folderPath); + if (!await folder.exists()) { + await folder.create(recursive: true); + folderCreationClosure(); + } + file = File(filePath); + await file?.create(); + folderCreationClosure(); + } + + 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_tab/step_counter/assets/logo.png b/open_earable/lib/apps_tab/step_counter/assets/logo.png new file mode 100644 index 0000000..1174f8c Binary files /dev/null and b/open_earable/lib/apps_tab/step_counter/assets/logo.png differ diff --git a/open_earable/lib/apps_tab/step_counter/assets/logo1.png b/open_earable/lib/apps_tab/step_counter/assets/logo1.png new file mode 100644 index 0000000..0c44ee0 Binary files /dev/null and b/open_earable/lib/apps_tab/step_counter/assets/logo1.png differ diff --git a/open_earable/lib/apps/stepCounter/step_counter.dart b/open_earable/lib/apps_tab/step_counter/step_counter.dart similarity index 99% rename from open_earable/lib/apps/stepCounter/step_counter.dart rename to open_earable/lib/apps_tab/step_counter/step_counter.dart index 14c9f73..536cedb 100644 --- a/open_earable/lib/apps/stepCounter/step_counter.dart +++ b/open_earable/lib/apps_tab/step_counter/step_counter.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:open_earable/widgets/earable_not_connected_warning.dart'; +import 'package:open_earable/shared/earable_not_connected_warning.dart'; import 'dart:async'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; diff --git a/open_earable/lib/apps_tab/tightness/assets/logo.png b/open_earable/lib/apps_tab/tightness/assets/logo.png new file mode 100644 index 0000000..9c8cf00 Binary files /dev/null and b/open_earable/lib/apps_tab/tightness/assets/logo.png differ diff --git a/open_earable/lib/apps_tab/tightness/tightness.dart b/open_earable/lib/apps_tab/tightness/tightness.dart new file mode 100644 index 0000000..279d05a --- /dev/null +++ b/open_earable/lib/apps_tab/tightness/tightness.dart @@ -0,0 +1,453 @@ +import 'package:flutter/material.dart'; +import 'package:open_earable/ble/ble_controller.dart'; +import 'package:open_earable/shared/earable_not_connected_warning.dart'; +import 'dart:async'; +import 'dart:math' as math; +import 'package:open_earable_flutter/src/open_earable_flutter.dart'; +import 'package:provider/provider.dart'; + +class TightnessMeter extends StatefulWidget { + final OpenEarable _openEarable; + TightnessMeter(this._openEarable); + @override + _TightnessMeterState createState() => _TightnessMeterState(_openEarable); +} + +class _TightnessMeterState extends State { + final OpenEarable _openEarable; + StreamSubscription? _imuSubscription; + _TightnessMeterState(this._openEarable); + bool _monitoring = false; + int lastTime = 0; + double x = 0; + double y = 0; + double z = 0; + double magnitude = 0; + double difficulty = 0; + int bpm = 80; + int score = 0; + int streak = 0; + int tightness = 0; + double nodThreshold = 4; // Time frame in milliseconds to consider for a nod + final List bpmList = [80, 100, 120, 170, 200]; + // Variables to keep track of nodding + DateTime lastNodTime = DateTime.now(); + + @override + void dispose() { + super.dispose(); + _imuSubscription?.cancel(); + } + + _setupListeners() { + _imuSubscription = + _openEarable.sensorManager.subscribeToSensorData(0).listen((data) { + if (!_monitoring) { + return; + } + int timestamp = data["timestamp"]; + setState(() { + lastTime = timestamp; + x = data["ACC"]["X"]; + y = data["ACC"]["Y"]; + z = data["ACC"]["Z"]; + }); + _processAccelerometerData(x, y, z); + }); + } + + void _processAccelerometerData(double x, double y, double z) { + // Calculate the overall acceleration magnitude + //print(x.toString() + y.toString() + z.toString()); + magnitude = _calculateMagnitude(x, y, z); + //print(magnitude); + + // Check if the magnitude exceeds the nodding threshold + if (magnitude > nodThreshold) { + DateTime now = DateTime.now(); + //print("Nod detected! 00000000000000000000000000000000"); + // Check if the last nod was within the time frame + if (now.difference(lastNodTime).inMilliseconds > + _bpmToMilliseconds(bpm) * 0.7) { + // Detected a nod + _isNodTight(lastNodTime, now); + lastNodTime = now; + } + } + } + + // Calculate the magnitude of the acceleration vector + double _calculateMagnitude(double x, double y, double z) { + return math.sqrt(x * x); + } + + // Check if the nod is tight + void _isNodTight(DateTime last, DateTime secondToLast) { + int difference = last.difference(secondToLast).inMilliseconds.abs(); + int expected = _bpmToMilliseconds(bpm); + if (_isWithinMargin(difference, expected, 25.0 - difficulty)) { + setState(() { + streak += 1; + }); + _updateScore(); + } else { + setState(() { + streak = 0; + tightness = 0; + }); + } + } + + void _updateScore() { + setState(() { + score = score + (((10 * streak) + difficulty) / tightness.abs()).round(); + }); + } + + bool _isWithinMargin( + int givenInterval, int expectedInterval, double marginPercentage) { + double margin = expectedInterval * marginPercentage / 100; + // Calculate the acceptable range + double lowerBound = expectedInterval - margin; + double upperBound = expectedInterval + margin; + setState(() { + tightness = + math.min(_bpmToMilliseconds(bpm), (givenInterval - expectedInterval)); + }); + return givenInterval >= lowerBound && givenInterval <= upperBound; + } + + int _bpmToMilliseconds(int bpm) { + if (bpm <= 0) { + throw ArgumentError("BPM must be greater than 0."); + } + return (60000 / bpm).round(); + } + + void startStopMonitoring() async { + if (_monitoring) { + setState(() { + _monitoring = false; + }); + _openEarable.audioPlayer.setState(AudioPlayerState.stop); + } else { + _setupListeners(); + setState(() { + _monitoring = true; + streak = 0; + }); + //start playing music + _setWAV(bpm.toString()); + } + } + + void _setWAV(String bpm) { + String fileName = bpm + ".wav"; + print("Setting source to wav file with file name '" + fileName + "'"); + _openEarable.audioPlayer.wavFile(fileName); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar( + title: Text('Tightness Meter'), + ), + body: Provider.of(context).connected + ? SingleChildScrollView( + child: Center( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(40.0), + bottomRight: Radius.circular(40.0), + topLeft: Radius.circular(40.0), + bottomLeft: Radius.circular(40.0)), + ), + width: 500, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Card( + margin: EdgeInsets.all(20), + color: Colors.black, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(20.0), + bottomRight: Radius.circular(20.0), + topLeft: Radius.circular(20.0), + bottomLeft: Radius.circular(20.0)), + ), + child: Row( + children: [ + Text( + 'Score:', + style: TextStyle(fontSize: 20), + ), + Spacer(), + Padding( + padding: EdgeInsets.all(5), + child: Text( + _monitoring ? score.toString() : '0', + style: TextStyle(fontSize: 20), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Container( + decoration: BoxDecoration( + color: (streak > 0) ? Colors.green : Colors.red, + borderRadius: BorderRadius.only( + topRight: Radius.circular(20.0), + bottomRight: Radius.circular(20.0), + topLeft: Radius.circular(20.0), + bottomLeft: Radius.circular(20.0)), + ), + padding: EdgeInsets.all(16), + child: Row( + children: [ + Text( + 'Streak:', + style: TextStyle(fontSize: 20), + ), + Spacer(), + Padding( + padding: EdgeInsets.all(5), + child: Text( + _monitoring ? streak.toString() : '0', + style: TextStyle(fontSize: 20), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(20.0), + bottomRight: Radius.circular(20.0), + topLeft: Radius.circular(20.0), + bottomLeft: Radius.circular(20.0)), + ), + padding: + EdgeInsets.only(top: 16, right: 16, left: 16), + child: Row( + children: [ + Text( + 'Tightness:', + style: TextStyle(fontSize: 20), + ), + Spacer(), + Padding( + padding: EdgeInsets.all(5), + child: Text( + _monitoring ? tightness.toString() : '0', + style: TextStyle(fontSize: 20), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(20.0), + bottomRight: Radius.circular(20.0), + topLeft: Radius.circular(20.0), + bottomLeft: Radius.circular(20.0)), + ), + child: Slider( + thumbColor: Colors.purple, + activeColor: Colors.grey, + secondaryActiveColor: Colors.purpleAccent, + inactiveColor: Colors.grey, + value: tightness.toDouble(), + min: -_bpmToMilliseconds(bpm).toDouble(), + max: _bpmToMilliseconds(bpm).toDouble(), + divisions: 2000, + label: tightness.toString(), + onChanged: (double value) { + setState(() {}); + }), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 20.0), + child: Row( + children: [ + Spacer(), + Text('Early'), + Spacer(flex: 5), + Text('Tight'), + Spacer(flex: 5), + Text('Late'), + Spacer() + ], + ), + ), + ], + ), + ), + Card( + margin: EdgeInsets.all(20), + color: Colors.black, + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(20), + child: ElevatedButton( + onPressed: startStopMonitoring, + style: ElevatedButton.styleFrom( + minimumSize: Size(1000, 80), + backgroundColor: _monitoring + ? Color(0xfff27777) + : Theme.of(context).colorScheme.secondary, + foregroundColor: Colors.black, + ), + child: Text( + _monitoring ? 'Stop' : 'Start', + style: TextStyle(fontSize: 30), + ), + ), + ), + ], + ), + ), + Card( + margin: EdgeInsets.all(20), + color: Colors.black, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Spacer(), + Text('BPM', style: TextStyle(fontSize: 30)), + Spacer(flex: 5), + DropdownButton( + style: TextStyle( + fontSize: 30, + ), + value: bpm, + icon: const Icon(Icons.arrow_drop_down), + onChanged: _monitoring + ? null + : (int? newValue) { + setState(() { + bpm = newValue!; + }); + }, + items: bpmList + .map>((int value) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + ), + Spacer(), + ], + ), + ), + Padding( + padding: EdgeInsets.only(top: 20), + child: Text('Sensitivity', + style: TextStyle(fontSize: 20)), + ), + Padding( + padding: EdgeInsets.all(16), + child: Column( + children: [ + Slider( + thumbColor: Colors.purple, + activeColor: Colors.purpleAccent, + secondaryActiveColor: Colors.purpleAccent, + inactiveColor: Colors.grey, + value: nodThreshold, + min: 1, + max: 20, + divisions: 10, + label: nodThreshold.round().toString(), + onChanged: (double value) { + setState(() { + nodThreshold = value; + }); + }), + Row( + children: [ + Spacer(), + Text('Cool Nodding'), + Spacer(flex: 10), + Text('Headbanging'), + Spacer() + ], + ), + ], + ), + ), + Padding( + padding: EdgeInsets.only(top: 10), + child: Text('Difficulty', + style: TextStyle(fontSize: 20)), + ), + Padding( + padding: EdgeInsets.all(16), + child: Column( + children: [ + Slider( + thumbColor: Colors.purple, + activeColor: Colors.purpleAccent, + secondaryActiveColor: Colors.purpleAccent, + inactiveColor: Colors.grey, + value: difficulty, + min: 0, + max: 25, + divisions: 10, + label: difficulty.round().toString(), + onChanged: (double value) { + setState(() { + difficulty = value; + }); + }), + Row( + children: [ + Spacer(), + Text('Beginner'), + Spacer(flex: 10), + Text('Impossible'), + Spacer() + ], + ), + ], + ), + ), + ], + ), + ), + SizedBox(height: 40), + ], + ), + ), + )) + : EarableNotConnectedWarning(), + ); + } +} diff --git a/open_earable/lib/ble.dart b/open_earable/lib/ble.dart deleted file mode 100644 index 346a0a5..0000000 --- a/open_earable/lib/ble.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:open_earable_flutter/src/open_earable_flutter.dart'; - -class BLEPage extends StatefulWidget { - final OpenEarable openEarable; - - BLEPage(this.openEarable); - - @override - _BLEPageState createState() => _BLEPageState(); -} - -class _BLEPageState extends State { - String _openEarableName = "OpenEarable"; - late OpenEarable _openEarable; - StreamSubscription? _scanSubscription; - StreamSubscription? _connectionStateStream; - List discoveredDevices = []; - - @override - void dispose() { - _scanSubscription?.cancel(); - _connectionStateStream?.cancel(); - super.dispose(); - } - - @override - void initState() { - super.initState(); - _openEarable = widget.openEarable; - - _startScanning(); - _setupListeners(); - } - - void _setupListeners() async { - _connectionStateStream = - _openEarable.bleManager.connectionStateStream.listen((connected) { - setState(() {}); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.background, - appBar: AppBar( - title: const Text('Bluetooth Devices'), - ), - body: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.fromLTRB(33, 16, 0, 0), - child: Text( - "SCANNED DEVICES", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12.0, - ), - ), - ), - Visibility( - visible: discoveredDevices.isNotEmpty, - child: Container( - margin: const EdgeInsets.fromLTRB(16, 0, 16, 0), - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey, - width: 1.0, - ), - borderRadius: BorderRadius.circular(8.0), - ), - child: ListView.builder( - padding: EdgeInsets.zero, - physics: - const NeverScrollableScrollPhysics(), // Disable scrolling, - shrinkWrap: true, - itemCount: discoveredDevices.length, - itemBuilder: (BuildContext context, int index) { - final device = discoveredDevices[index]; - return Column(children: [ - Material( - type: MaterialType.transparency, - child: ListTile( - selectedTileColor: Colors.grey, - title: Text(device.name), - titleTextStyle: const TextStyle(fontSize: 16), - visualDensity: const VisualDensity( - horizontal: -4, vertical: -4), - trailing: _buildTrailingWidget(device.id), - onTap: () { - _connectToDevice(device); - }, - )), - if (index != discoveredDevices.length - 1) - const Divider( - height: 1.0, - thickness: 1.0, - color: Colors.grey, - indent: 16.0, - endIndent: 0.0, - ), - ]); - }, - ))), - Center( - child: Padding( - padding: EdgeInsets.all(16), - child: Text( - (_openEarable.bleManager.deviceIdentifier != null && - _openEarable.bleManager.deviceFirmwareVersion != - null) - ? "Connected to ${_openEarable.bleManager.deviceIdentifier} ${_openEarable.bleManager.deviceFirmwareVersion}" - : "If your OpenEarable device is not shown here, try restarting it", - style: const TextStyle(fontSize: 16), - textAlign: TextAlign.center, - ))), - Center( - child: ElevatedButton( - onPressed: _startScanning, - style: ButtonStyle( - foregroundColor: MaterialStateProperty.all( - Theme.of(context).colorScheme.primary), - backgroundColor: MaterialStateProperty.all( - Theme.of(context).colorScheme.secondary), - ), - child: const Text('Restart Scan'), - ), - ) - ], - )), - ); - } - - Widget _buildTrailingWidget(String id) { - if (_openEarable.bleManager.connectedDevice?.id == id) { - return Icon( - size: 24, - Icons.check, - color: Theme.of(context).colorScheme.secondary); - } else if (_openEarable.bleManager.connectingDevice?.id == id) { - return const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 2)); - } - return const SizedBox.shrink(); - } - - void _startScanning() async { - discoveredDevices = []; - if (_openEarable.bleManager.connectedDevice != null) { - discoveredDevices.add(_openEarable.bleManager.connectedDevice); - } - setState(() {}); // Update UI - await _openEarable.bleManager.startScan(); - _scanSubscription?.cancel(); - _scanSubscription = - _openEarable.bleManager.scanStream.listen((incomingDevice) { - if (incomingDevice.name.isNotEmpty && - incomingDevice.name.contains(_openEarableName) && - !discoveredDevices.any((device) => device.id == incomingDevice.id)) { - setState(() { - discoveredDevices.add(incomingDevice); - }); - } - }); - } - - void _connectToDevice(device) { - _scanSubscription?.cancel(); - _openEarable.bleManager.connectToDevice(device); - } -} diff --git a/open_earable/lib/ble/ble_connect_view.dart b/open_earable/lib/ble/ble_connect_view.dart new file mode 100644 index 0000000..352c91c --- /dev/null +++ b/open_earable/lib/ble/ble_connect_view.dart @@ -0,0 +1,161 @@ +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:open_earable/ble/ble_controller.dart'; +import 'package:open_earable_flutter/src/open_earable_flutter.dart'; +import 'package:provider/provider.dart'; + +class BLEPage extends StatefulWidget { + final OpenEarable openEarable; + + BLEPage(this.openEarable); + + @override + _BLEPageState createState() => _BLEPageState(); +} + +class _BLEPageState extends State { + final String _pageTitle = "Bluetooth Devices"; + late OpenEarable _openEarable; + @override + void initState() { + super.initState(); + _openEarable = widget.openEarable; + Provider.of(context, listen: false).startScanning(); + } + + @override + Widget build(BuildContext context) { + return Platform.isIOS + ? CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text(_pageTitle)), + child: SafeArea( + child: _getBody(), + )) + : Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar( + title: Text(_pageTitle), + ), + body: _getBody()); + } + + Widget _getBody() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(33, 16, 0, 0), + child: Text( + "SCANNED DEVICES", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12.0, + ), + ), + ), + Consumer(builder: (context, controller, child) { + return Visibility( + visible: controller.discoveredDevices.isNotEmpty, + child: Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 0), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey, + width: 1.0, + ), + borderRadius: BorderRadius.circular(8.0), + ), + child: ListView.builder( + padding: EdgeInsets.zero, + physics: + const NeverScrollableScrollPhysics(), // Disable scrolling, + shrinkWrap: true, + itemCount: controller.discoveredDevices.length, + itemBuilder: (BuildContext context, int index) { + final device = controller.discoveredDevices[index]; + return Column(children: [ + Material( + type: MaterialType.transparency, + child: ListTile( + selectedTileColor: Colors.grey, + title: Text(device.name), + titleTextStyle: const TextStyle(fontSize: 16), + visualDensity: const VisualDensity( + horizontal: -4, vertical: -4), + trailing: _buildTrailingWidget(device.id), + onTap: () { + controller.connectToDevice(device); + }, + )), + if (index != controller.discoveredDevices.length - 1) + const Divider( + height: 1.0, + thickness: 1.0, + color: Colors.grey, + indent: 16.0, + endIndent: 0.0, + ), + ]); + }, + ))); + }), + Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + (_openEarable.bleManager.deviceIdentifier != null && + _openEarable.bleManager.deviceFirmwareVersion != null) + ? "Connected to ${_openEarable.bleManager.deviceIdentifier} ${_openEarable.bleManager.deviceFirmwareVersion}" + : "If your OpenEarable device is not shown here, try restarting it", + style: const TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ))), + Center( + child: Platform.isIOS + ? CupertinoButton( + padding: EdgeInsets.fromLTRB(16, 0, 16, 0), + child: const Text('Restart Scan'), + color: CupertinoTheme.of(context) + .primaryColor, // iOS equivalent for a prominent button color + onPressed: + Provider.of(context, listen: false) + .startScanning, + ) + : ElevatedButton( + onPressed: + Provider.of(context, listen: false) + .startScanning, + style: ButtonStyle( + foregroundColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.primary), + backgroundColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.secondary), + ), + child: const Text('Restart Scan'), + ), + ) + ], + )); + } + + Widget _buildTrailingWidget(String id) { + if (_openEarable.bleManager.connectedDevice?.id == id) { + return Icon( + size: 24, + Platform.isIOS ? CupertinoIcons.check_mark : Icons.check, + color: Platform.isIOS + ? CupertinoTheme.of(context).primaryColor + : Theme.of(context).colorScheme.secondary); + } else if (_openEarable.bleManager.connectingDevice?.id == id) { + return SizedBox( + height: 24, + width: 24, + child: Platform.isIOS + ? CupertinoActivityIndicator() + : CircularProgressIndicator(strokeWidth: 2)); + } + return const SizedBox.shrink(); + } +} diff --git a/open_earable/lib/ble/ble_controller.dart b/open_earable/lib/ble/ble_controller.dart new file mode 100644 index 0000000..90d612f --- /dev/null +++ b/open_earable/lib/ble/ble_controller.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:open_earable_flutter/src/open_earable_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class BluetoothController extends ChangeNotifier { + late SharedPreferences prefs; + + BluetoothController() { + _initializeSharedPreferences(); + } + + String _openEarableName = "OpenEarable"; + OpenEarable? _openEarable; + StreamSubscription? _scanSubscription; + StreamSubscription? _connectionStateSubscription; + StreamSubscription? _batteryLevelSubscription; + + List _discoveredDevices = []; + List get discoveredDevices => _discoveredDevices; + + bool _scanning = false; + + bool _connected = false; + bool get connected => _connected; + + int? _earableSOC = null; + int? get earableSOC => _earableSOC; + + Future _initializeSharedPreferences() async { + prefs = await SharedPreferences.getInstance(); + } + + void set openEarable(OpenEarable openEarable) { + _openEarable = openEarable; + _connectionStateSubscription?.cancel(); + + _connectionStateSubscription = + _openEarable?.bleManager.connectionStateStream.listen((connected) { + _connected = connected; + if (connected) { + _getSOC(); + } else { + startScanning(); + } + notifyListeners(); + }); + } + + @override + void dispose() { + super.dispose(); + _scanSubscription?.cancel(); + _scanning = false; + _connectionStateSubscription?.cancel(); + _batteryLevelSubscription?.cancel(); + } + + void _getSOC() { + _batteryLevelSubscription = _openEarable?.sensorManager + .getBatteryLevelStream() + .listen((batteryLevel) { + _earableSOC = batteryLevel[0].toInt(); + notifyListeners(); + }); + } + + Future startScanning() async { + _scanSubscription?.cancel(); + _scanning = true; + _discoveredDevices = []; + if (_openEarable == null) { + return; + } + if (_openEarable?.bleManager.connectedDevice != null) { + discoveredDevices.add((_openEarable?.bleManager.connectedDevice)!); + } + await _openEarable?.bleManager.startScan(); + _scanSubscription?.cancel(); + _scanSubscription = + _openEarable?.bleManager.scanStream.listen((incomingDevice) { + if (incomingDevice.name.isNotEmpty && + incomingDevice.name.contains(_openEarableName) && + !discoveredDevices.any((device) => device.id == incomingDevice.id)) { + discoveredDevices.add(incomingDevice); + notifyListeners(); + } + }); + } + + void connectToDevice(device) { + if (device.name == _openEarable?.bleManager.connectedDevice?.name || + device.name == _openEarable?.bleManager.connectingDevice?.name) { + return; + } + _scanSubscription?.cancel(); + _scanning = false; + _openEarable?.bleManager.connectToDevice(device); + prefs.setString("lastConnectedDeviceName", device.name); + } +} diff --git a/open_earable/lib/controls_tab/controls_tab.dart b/open_earable/lib/controls_tab/controls_tab.dart index dfe3187..8ee6f75 100644 --- a/open_earable/lib/controls_tab/controls_tab.dart +++ b/open_earable/lib/controls_tab/controls_tab.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; +import 'dart:io'; import 'views/sensor_configuration.dart'; import 'views/connect.dart'; import 'views/led_color.dart'; import 'views/audio_player.dart'; import 'dart:async'; -import 'models/open_earable_settings.dart'; class ControlTab extends StatefulWidget { final OpenEarable _openEarable; @@ -20,8 +20,6 @@ class _ControlTabState extends State { StreamSubscription? _connectionStateSubscription; StreamSubscription? _batteryLevelSubscription; - bool connected = false; - int earableSOC = 0; bool earableCharging = false; @override @@ -31,53 +29,20 @@ class _ControlTabState extends State { _batteryLevelSubscription?.cancel(); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _connectionStateSubscription = - _openEarable.bleManager.connectionStateStream.listen((connected) { - OpenEarableSettings().resetState(); - setState(() { - this.connected = connected; - - if (connected) { - getNameAndSOC(); - } - }); - }); - } - - @override - void initState() { - super.initState(); - connected = _openEarable.bleManager.connected; - if (connected) { - getNameAndSOC(); - } - } - - void getNameAndSOC() { - _batteryLevelSubscription = _openEarable.sensorManager - .getBatteryLevelStream() - .listen((batteryLevel) { - setState(() { - earableSOC = batteryLevel[0].toInt(); - }); - }); - } - @override Widget build(BuildContext context) { return SingleChildScrollView( child: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), + onTap: () => Platform.isIOS + ? FocusScope.of(context).requestFocus(FocusNode()) + : FocusScope.of(context).unfocus(), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( height: 5, ), - ConnectCard(_openEarable, earableSOC), + ConnectCard(_openEarable), SensorConfigurationCard(_openEarable), AudioPlayerCard(_openEarable), LEDColorCard(_openEarable), diff --git a/open_earable/lib/controls_tab/models/open_earable_settings.dart b/open_earable/lib/controls_tab/models/open_earable_settings.dart index 0e5a4a2..4557f7a 100644 --- a/open_earable/lib/controls_tab/models/open_earable_settings.dart +++ b/open_earable/lib/controls_tab/models/open_earable_settings.dart @@ -22,22 +22,22 @@ class OpenEarableSettings { "50000", "62500" ]; - final Map jingleMap = { - 0: 'IDLE', - 1: 'NOTIFICATION', - 2: 'SUCCESS', - 3: 'ERROR', - 4: 'ALARM', - 5: 'PING', - 6: 'OPEN', - 7: 'CLOSE', - 8: 'CLICK', + final Map jingleMap = { + 'IDLE': 0, + 'NOTIFICATION': 1, + 'SUCCESS': 2, + 'ERROR': 3, + 'ALARM': 4, + 'PING': 5, + 'OPEN': 6, + 'CLOSE': 7, + 'CLICK': 8, }; - final Map waveFormMap = { - 1: 'SINE', - 2: 'SQUARE', - 3: 'TRIANGLE', - 4: 'SAW', + final Map waveFormMap = { + 'SINE': 0, + 'SQUARE': 1, + 'TRIANGLE': 2, + 'SAW': 3, }; late bool imuSettingSelected; @@ -64,9 +64,9 @@ class OpenEarableSettings { selectedBarometerOption = "0"; selectedMicrophoneOption = "0"; - selectedAudioPlayerRadio = 3; // no radio is selected - selectedJingle = jingleMap[1]!; - selectedWaveForm = waveFormMap[1]!; + selectedAudioPlayerRadio = 0; + selectedJingle = jingleMap.keys.first; + selectedWaveForm = waveFormMap.keys.first; selectedFilename = "filename.wav"; selectedFrequency = "440"; selectedFrequencyVolume = "50"; @@ -76,19 +76,10 @@ class OpenEarableSettings { } int getWaveFormIndex(String value) { - return _getKeyFromValue(value, waveFormMap); + return waveFormMap[value] ?? 0; } int getJingleIndex(String value) { - return _getKeyFromValue(value, jingleMap); - } - - int _getKeyFromValue(String value, Map map) { - for (var entry in map.entries) { - if (entry.value == value) { - return entry.key; - } - } - return 1; + return jingleMap[value] ?? 0; } } diff --git a/open_earable/lib/controls_tab/views/audio_player.dart b/open_earable/lib/controls_tab/views/audio_player.dart index f5224b4..cc295fe 100644 --- a/open_earable/lib/controls_tab/views/audio_player.dart +++ b/open_earable/lib/controls_tab/views/audio_player.dart @@ -1,6 +1,11 @@ +import 'dart:io'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:open_earable/ble/ble_controller.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; +import 'package:provider/provider.dart'; import '../models/open_earable_settings.dart'; +import '../../shared/dynamic_value_picker.dart'; class AudioPlayerCard extends StatefulWidget { final OpenEarable _openEarable; @@ -16,40 +21,37 @@ class _AudioPlayerCardState extends State { late TextEditingController _filenameTextController; late TextEditingController _jingleTextController; + late TextEditingController _frequencyTextController; late TextEditingController _frequencyVolumeTextController; late TextEditingController _waveFormTextController; - + late bool _connected; @override void initState() { super.initState(); _filenameTextController = TextEditingController( text: "${OpenEarableSettings().selectedFilename}"); - _jingleTextController = - TextEditingController(text: OpenEarableSettings().selectedJingle); _frequencyTextController = TextEditingController( text: "${OpenEarableSettings().selectedFrequency}"); _frequencyVolumeTextController = TextEditingController( text: "${OpenEarableSettings().selectedFrequencyVolume}"); _waveFormTextController = TextEditingController(text: OpenEarableSettings().selectedWaveForm); + _connected = + Provider.of(context, listen: false).connected; } void updateText() { - if (_openEarable.bleManager.connected) { + if (_connected) { OpenEarableSettings().selectedFilename = _filenameTextController.text; - OpenEarableSettings().selectedJingle = _jingleTextController.text; OpenEarableSettings().selectedFrequency = _frequencyTextController.text; OpenEarableSettings().selectedFrequencyVolume = _frequencyVolumeTextController.text; - OpenEarableSettings().selectedWaveForm = _waveFormTextController.text; } else { _filenameTextController.text = OpenEarableSettings().selectedFilename; - _jingleTextController.text = OpenEarableSettings().selectedJingle; _frequencyTextController.text = OpenEarableSettings().selectedFrequency; _frequencyVolumeTextController.text = OpenEarableSettings().selectedFrequencyVolume; - _waveFormTextController.text = OpenEarableSettings().selectedWaveForm; } } @@ -160,7 +162,7 @@ class _AudioPlayerCardState extends State { mainAxisSize: MainAxisSize.min, children: soundsMap.values.map((String option) { return ListTile( - onTap: _openEarable.bleManager.connected + onTap: _connected ? () { setState(() { textController.text = option; @@ -179,253 +181,262 @@ class _AudioPlayerCardState extends State { @override Widget build(BuildContext context) { - updateText(); return Padding( - padding: const EdgeInsets.symmetric(horizontal: 5.0), - child: Card( - //Audio Player Card - color: Theme.of(context).colorScheme.primary, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Audio Player', - style: TextStyle( - color: Colors.white, - fontSize: 18.0, - fontWeight: FontWeight.bold, + padding: const EdgeInsets.symmetric(horizontal: 5.0), + child: Selector( + selector: (_, bleController) => bleController.connected, + builder: (context, connected, child) { + _connected = connected; + updateText(); + return Card( + //Audio Player Card + color: Platform.isIOS + ? CupertinoTheme.of(context).primaryContrastingColor + : Theme.of(context).colorScheme.primary, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Player', + style: TextStyle( + color: Colors.white, + fontSize: 18.0, + fontWeight: FontWeight.bold, + ), + ), + _getFileNameRow(), + _getJingleRow(), + _getFrequencyRow(), + SizedBox(height: 12), + Platform.isIOS + ? _getCupertinoButtonRow() + : _getMaterialButtonRow(), + ], + ), ), - ), - _getFileNameRow(), - _getJingleRow(), - _getFrequencyRow(), - _getButtonRow(), - ], - ), - ), - ), - ); + ); + })); } Widget _getAudioPlayerRadio(int index) { - return Radio( - value: index, - groupValue: OpenEarableSettings().selectedAudioPlayerRadio, - onChanged: !_openEarable.bleManager.connected - ? null - : (int? value) { - setState(() { - OpenEarableSettings().selectedAudioPlayerRadio = value ?? 0; - }); - }, - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { - return Theme.of(context).colorScheme.secondary; - } - return Colors.grey; - }), - ); + return SizedBox( + height: 37, + width: 44, + child: Platform.isIOS + ? CupertinoRadio( + value: index, + groupValue: OpenEarableSettings().selectedAudioPlayerRadio, + onChanged: !_connected + ? null + : (int? value) { + setState(() { + OpenEarableSettings().selectedAudioPlayerRadio = + value ?? 0; + }); + }, + activeColor: CupertinoTheme.of(context).primaryColor, + fillColor: CupertinoTheme.of(context).primaryContrastingColor, + inactiveColor: + CupertinoTheme.of(context).primaryContrastingColor, + ) + : Radio( + value: index, + groupValue: OpenEarableSettings().selectedAudioPlayerRadio, + onChanged: !_connected + ? null + : (int? value) { + setState(() { + OpenEarableSettings().selectedAudioPlayerRadio = + value ?? 0; + }); + }, + fillColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return Theme.of(context).colorScheme.secondary; + } + return Colors.grey; + }), + )); } Widget _getFileNameRow() { - return Row( - children: [ - _getAudioPlayerRadio(0), - Expanded( - child: SizedBox( - height: 37.0, - child: TextField( - controller: _filenameTextController, - obscureText: false, - enabled: _openEarable.bleManager.connected, - style: TextStyle( - color: _openEarable.bleManager.connected - ? Colors.black - : Colors.grey), - decoration: InputDecoration( - contentPadding: EdgeInsets.all(10), - border: OutlineInputBorder(), - floatingLabelBehavior: FloatingLabelBehavior.never, - labelStyle: TextStyle( - color: _openEarable.bleManager.connected - ? Colors.black - : Colors.grey), - filled: true, - fillColor: _openEarable.bleManager.connected - ? Colors.white - : Colors.grey[200], - ), + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: EdgeInsets.fromLTRB(44, 0, 0, 0), + child: Text( + "Audio File", + style: TextStyle( + color: Color.fromRGBO(168, 168, 172, 1.0), ), + )), + Row( + children: [ + _getAudioPlayerRadio(0), + Expanded( + child: SizedBox( + height: 37.0, + child: _fileNameTextField( + _filenameTextController, TextInputType.text, null, null)), ), + ], + ) + ]); + } + + Widget _fileNameTextField(TextEditingController textController, + TextInputType keyboardType, String? placeholder, int? maxLength) { + if (Platform.isIOS) { + return CupertinoTextField( + cursorColor: Colors.blue, + controller: textController, + obscureText: false, + placeholder: placeholder, + style: TextStyle( + color: _connected ? Colors.black : Colors.grey, ), - ], - ); + padding: EdgeInsets.fromLTRB(8, 0, 0, 0), + textAlignVertical: TextAlignVertical.center, + textInputAction: TextInputAction.done, + onSubmitted: (_) { + FocusScope.of(context).requestFocus(FocusNode()); + }, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4.0), + ), + placeholderStyle: TextStyle( + color: _connected ? Colors.black : Colors.grey, + ), + keyboardType: keyboardType, + maxLength: maxLength, + maxLines: 1, + ); + } else { + return TextField( + controller: textController, + obscureText: false, + enabled: _connected, + style: TextStyle(color: _connected ? Colors.black : Colors.grey), + decoration: InputDecoration( + labelText: placeholder, + contentPadding: EdgeInsets.all(8), + border: OutlineInputBorder(), + floatingLabelBehavior: FloatingLabelBehavior.never, + labelStyle: TextStyle(color: _connected ? Colors.black : Colors.grey), + filled: true, + fillColor: _connected ? Colors.white : Colors.grey[200], + ), + keyboardType: keyboardType, + maxLength: maxLength, + maxLines: 1, + ); + } } Widget _getJingleRow() { - return Row( - children: [ - _getAudioPlayerRadio(1), - Expanded( - child: SizedBox( - height: 37.0, - child: InkWell( - onTap: _openEarable.bleManager.connected - ? () { - _showSoundPicker(context, OpenEarableSettings().jingleMap, - _jingleTextController); - } - : null, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12.0), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _jingleTextController.text, - style: TextStyle(fontSize: 16.0), - ), - Icon(Icons.arrow_drop_down), - ], - ), + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: EdgeInsets.fromLTRB(44, 8, 0, 0), + child: Text( + "Jingle", + style: TextStyle( + color: Color.fromRGBO(168, 168, 172, 1.0), + ), + )), + Row( + children: [ + _getAudioPlayerRadio(1), + Expanded( + child: SizedBox( + height: 37.0, + child: DynamicValuePicker( + context, + OpenEarableSettings().jingleMap.keys.toList(), + OpenEarableSettings().selectedJingle, + (newValue) { + setState(() { + OpenEarableSettings().selectedJingle = newValue; + }); + }, + (_) => null, + _connected, ), ), ), - ), - ], - ); + ], + ) + ]); } Widget _getFrequencyRow() { - return Row( - children: [ - _getAudioPlayerRadio(2), - SizedBox( - height: 37.0, - width: 75, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 0), - child: TextField( - controller: _frequencyTextController, - textAlign: TextAlign.end, - style: TextStyle( - color: _openEarable.bleManager.connected - ? Colors.black - : Colors.grey), - decoration: InputDecoration( - contentPadding: EdgeInsets.all(10), - floatingLabelBehavior: FloatingLabelBehavior.never, - border: OutlineInputBorder(), - labelText: '440', - filled: true, - labelStyle: TextStyle( - color: _openEarable.bleManager.connected - ? Colors.black - : Colors.grey), - fillColor: _openEarable.bleManager.connected - ? Colors.white - : Colors.grey[200], - ), - keyboardType: TextInputType.number, - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 5), + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: EdgeInsets.fromLTRB(44, 8, 0, 0), child: Text( - 'Hz', - style: TextStyle( - color: _openEarable.bleManager.connected - ? Colors.white - : Colors.grey), // Set text color to white - ), - ), - Spacer(), - SizedBox( - height: 37.0, - width: 52, - child: TextField( - controller: _frequencyVolumeTextController, - textAlign: TextAlign.end, - autofocus: false, + "Frequency", style: TextStyle( - color: _openEarable.bleManager.connected - ? Colors.black - : Colors.grey), - decoration: InputDecoration( - contentPadding: EdgeInsets.all(10), - floatingLabelBehavior: FloatingLabelBehavior.never, - border: OutlineInputBorder(), - labelText: '50', - filled: true, - isDense: true, - counterText: "", - labelStyle: TextStyle( - color: _openEarable.bleManager.connected - ? Colors.black - : Colors.grey), - fillColor: _openEarable.bleManager.connected - ? Colors.white - : Colors.grey[200], + color: Color.fromRGBO(168, 168, 172, 1.0), + ), + )), + Row( + children: [ + _getAudioPlayerRadio(2), + SizedBox( + height: 37.0, + width: 75, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 0), + child: _fileNameTextField(_frequencyTextController, + TextInputType.number, "440", null))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: Text( + 'Hz', + style: TextStyle( + color: _connected + ? Colors.white + : Colors.grey), // Set text color to white ), - maxLength: 3, - maxLines: 1, - keyboardType: TextInputType.number, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 5), - child: Text( - '%', - style: TextStyle( - color: _openEarable.bleManager.connected - ? Colors.white - : Colors.grey), // Set text color to white ), - ), - Spacer(), - SizedBox( - height: 37.0, - width: 107, - child: InkWell( - onTap: _openEarable.bleManager.connected - ? () { - _showSoundPicker(context, OpenEarableSettings().waveFormMap, - _waveFormTextController); - } - : null, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12.0), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - _waveFormTextController.text, - style: TextStyle(fontSize: 16.0), - overflow: TextOverflow.ellipsis, - softWrap: false, - ), - ), - Icon(Icons.arrow_drop_down), - ], - ), + Spacer(), + SizedBox( + height: 37.0, + width: 52, + child: _fileNameTextField(_frequencyVolumeTextController, + TextInputType.number, "50", 3)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: Text( + '%', + style: TextStyle( + color: _connected + ? Colors.white + : Colors.grey), // Set text color to white ), ), - ), - ], - ); + Spacer(), + SizedBox( + height: 37.0, + width: 107, + child: DynamicValuePicker( + context, + OpenEarableSettings().waveFormMap.keys.toList(), + OpenEarableSettings().selectedWaveForm, (newValue) { + setState( + () { + OpenEarableSettings().selectedWaveForm = newValue; + }, + ); + }, (_) => null, _connected), + ), + ], + ) + ]); } - Widget _getButtonRow() { + Widget _getMaterialButtonRow() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, // Align buttons to the space between @@ -433,9 +444,7 @@ class _AudioPlayerCardState extends State { SizedBox( width: 120, child: ElevatedButton( - onPressed: _openEarable.bleManager.connected - ? _setSourceButtonPressed - : null, + onPressed: _connected ? _setSourceButtonPressed : null, style: ElevatedButton.styleFrom( backgroundColor: Color(0xff53515b), foregroundColor: Colors.white, @@ -444,8 +453,7 @@ class _AudioPlayerCardState extends State { ), ), ElevatedButton( - onPressed: - _openEarable.bleManager.connected ? _playButtonPressed : null, + onPressed: _connected ? _playButtonPressed : null, style: ElevatedButton.styleFrom( backgroundColor: Color(0xff77F2A1), foregroundColor: Colors.black, @@ -453,8 +461,7 @@ class _AudioPlayerCardState extends State { child: Icon(Icons.play_arrow_outlined), ), ElevatedButton( - onPressed: - _openEarable.bleManager.connected ? _pauseButtonPressed : null, + onPressed: _connected ? _pauseButtonPressed : null, style: ElevatedButton.styleFrom( backgroundColor: Color(0xffe0f277), foregroundColor: Colors.black, @@ -462,8 +469,7 @@ class _AudioPlayerCardState extends State { child: Icon(Icons.pause), ), ElevatedButton( - onPressed: - _openEarable.bleManager.connected ? _stopButtonPressed : null, + onPressed: _connected ? _stopButtonPressed : null, style: ElevatedButton.styleFrom( backgroundColor: Color(0xfff27777), foregroundColor: Colors.black, @@ -473,4 +479,49 @@ class _AudioPlayerCardState extends State { ], ); } + + Widget _getCupertinoButtonRow() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: 120, + child: CupertinoButton( + padding: EdgeInsets.all(0), + onPressed: _connected ? _setSourceButtonPressed : null, + color: Color(0xff53515b), + child: Text( + 'Set\nSource', + style: TextStyle( + color: _connected ? Colors.white : null, fontSize: 15), + ), + ), + ), + SizedBox(width: 4), + Expanded( + child: CupertinoButton( + padding: EdgeInsets.all(0), + onPressed: _connected ? _playButtonPressed : null, + color: CupertinoTheme.of(context).primaryColor, + child: Icon(CupertinoIcons.play), + )), + SizedBox(width: 4), + Expanded( + child: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: _connected ? _pauseButtonPressed : null, + color: Color(0xffe0f277), + child: Icon(CupertinoIcons.pause), + )), + SizedBox(width: 4), + Expanded( + child: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: _connected ? _stopButtonPressed : null, + color: Color(0xfff27777), + child: Icon(CupertinoIcons.stop), + )), + ], + ); + } } diff --git a/open_earable/lib/controls_tab/views/connect.dart b/open_earable/lib/controls_tab/views/connect.dart index 7da1668..393158e 100644 --- a/open_earable/lib/controls_tab/views/connect.dart +++ b/open_earable/lib/controls_tab/views/connect.dart @@ -1,43 +1,141 @@ +import 'dart:io'; +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:open_earable/ble/ble_controller.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; -import '../../ble.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import 'package:open_earable/ble/ble_connect_view.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -class ConnectCard extends StatelessWidget { +class ConnectCard extends StatefulWidget { final OpenEarable _openEarable; - final int _earableSOC; + ConnectCard(this._openEarable); - ConnectCard(this._openEarable, this._earableSOC); + @override + _ConnectCard createState() => _ConnectCard(this._openEarable); +} + +class _ConnectCard extends State { + final OpenEarable _openEarable; + bool _autoConnectEnabled = false; + late SharedPreferences prefs; + + _ConnectCard(this._openEarable); + + @override + void initState() { + super.initState(); + _getPrefs(); + } + + @override + void dispose() { + super.dispose(); + } + + Future _getPrefs() async { + prefs = await SharedPreferences.getInstance(); + setState(() { + _autoConnectEnabled = prefs.getBool("autoConnectEnabled") ?? false; + }); + _startAutoConnectScan(); + } + + void _startAutoConnectScan() { + if (_autoConnectEnabled == true) { + Provider.of(context, listen: false).startScanning(); + } + } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 5.0), child: Card( - color: Theme.of(context).colorScheme.primary, + color: Platform.isIOS + ? CupertinoTheme.of(context).primaryContrastingColor + : Theme.of(context).colorScheme.primary, child: Padding( padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Device', - style: TextStyle( - color: Colors.white, - fontSize: 18.0, - fontWeight: FontWeight.bold, + child: Consumer( + builder: (context, bleController, child) { + List devices = bleController.discoveredDevices; + _tryAutoconnect(devices, bleController); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Device', + style: TextStyle( + color: Colors.white, + fontSize: 18.0, + fontWeight: FontWeight.bold, + ), ), - ), - SizedBox(height: 5), - _getEarableInfo(), - _getConnectButton(context), - ], - ), + Row( + children: [ + Platform.isIOS + ? CupertinoCheckbox( + value: _autoConnectEnabled, + onChanged: (value) => { + setState(() { + _autoConnectEnabled = value ?? false; + _startAutoConnectScan(); + if (value != null) + prefs.setBool("autoConnectEnabled", value); + }) + }, + activeColor: _autoConnectEnabled + ? CupertinoTheme.of(context).primaryColor + : CupertinoTheme.of(context) + .primaryContrastingColor, + checkColor: CupertinoTheme.of(context) + .primaryContrastingColor, + ) + : Checkbox( + checkColor: Theme.of(context).colorScheme.primary, + //fillColor: Theme.of(context).colorScheme.primary, + value: _autoConnectEnabled, + onChanged: (value) => { + setState(() { + _autoConnectEnabled = value ?? false; + _startAutoConnectScan(); + if (value != null) + prefs.setBool("autoConnectEnabled", value); + }) + }, + ), + Text( + "Connect to OpenEarable automatically", + style: TextStyle( + color: Color.fromRGBO(168, 168, 172, 1.0), + ), + ) + ], + ), + SizedBox(height: 5), + _getEarableInfo(bleController), + _getConnectButton(context), + ], + ); + }), ), ), ); } - Widget _getEarableInfo() { + String _batteryPercentageString(BluetoothController bleController) { + int? percentage = bleController.earableSOC; + if (percentage == null) { + return " (...%)"; + } else { + return " ($percentage%)"; + } + } + + Widget _getEarableInfo(BluetoothController bleController) { return Row( children: [ if (_openEarable.bleManager.connected) @@ -45,14 +143,21 @@ class ConnectCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "${_openEarable.bleManager.connectedDevice?.name ?? ""} (${_earableSOC}%)", + "${_openEarable.bleManager.connectedDevice?.name ?? ""}${_batteryPercentageString(bleController)}", + style: TextStyle( + color: Color.fromRGBO(168, 168, 172, 1.0), + fontSize: 15.0, + ), + ), + Text( + "Firmware: ${_openEarable.deviceFirmwareVersion ?? "not available"}", style: TextStyle( color: Color.fromRGBO(168, 168, 172, 1.0), fontSize: 15.0, ), ), Text( - "Firmware ${_openEarable.deviceFirmwareVersion ?? "0.0.0"}", + "Hardware: ${_openEarable.deviceHardwareVersion ?? "not available"}", style: TextStyle( color: Color.fromRGBO(168, 168, 172, 1.0), fontSize: 15.0, @@ -76,33 +181,48 @@ class ConnectCard extends StatelessWidget { Widget _getConnectButton(BuildContext context) { return Visibility( visible: !_openEarable.bleManager.connected, - child: Column( - children: [ - SizedBox(height: 10), - Row( - children: [ - Expanded( - child: SizedBox( - height: 37.0, - child: ElevatedButton( - onPressed: () { - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => BLEPage(_openEarable))); - }, - style: ElevatedButton.styleFrom( - backgroundColor: !_openEarable.bleManager.connected - ? Color(0xff77F2A1) - : Color(0xfff27777), - foregroundColor: Colors.black, - ), - child: Text("Connect"), + child: Container( + height: 37, + width: double.infinity, + child: !Platform.isIOS + ? ElevatedButton( + onPressed: () => _connectButtonAction(context), + style: ElevatedButton.styleFrom( + backgroundColor: !_openEarable.bleManager.connected + ? Color(0xff77F2A1) + : Color(0xfff27777), + foregroundColor: Colors.black, ), - ), - ), - ], - ), - ], - ), + child: Text("Connect"), + ) + : CupertinoButton( + padding: EdgeInsets.zero, + color: CupertinoTheme.of(context).primaryColor, + child: Text("Connect"), + onPressed: () => _connectButtonAction(context))), ); } + + _connectButtonAction(BuildContext context) { + Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => BLEPage(_openEarable))); + } + + void _tryAutoconnect( + List devices, BluetoothController bleController) async { + if (_autoConnectEnabled != true || + devices.isEmpty || + bleController.connected) { + return; + } + String? lastConnectedDeviceName = + prefs.getString("lastConnectedDeviceName"); + DiscoveredDevice? deviceToConnect = devices.firstWhere( + (device) => device.name == lastConnectedDeviceName, + orElse: () => devices[0]); + if (_openEarable.bleManager.connectingDevice?.name != + deviceToConnect.name) { + bleController.connectToDevice(deviceToConnect); + } + } } diff --git a/open_earable/lib/controls_tab/views/led_color.dart b/open_earable/lib/controls_tab/views/led_color.dart index 253d450..c97cf84 100644 --- a/open_earable/lib/controls_tab/views/led_color.dart +++ b/open_earable/lib/controls_tab/views/led_color.dart @@ -1,8 +1,14 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:open_earable/controls_tab/models/open_earable_settings.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; import 'dart:async'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'dart:io'; +import 'package:provider/provider.dart'; +import 'package:open_earable/ble/ble_controller.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:open_earable/shared/global_theme.dart'; class LEDColorCard extends StatefulWidget { final OpenEarable _openEarable; @@ -113,35 +119,84 @@ class _LEDColorCardState extends State { */ void _openColorPicker() { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Pick a color for the RGB LED'), - content: SingleChildScrollView( - child: ColorPicker( - pickerColor: OpenEarableSettings().selectedColor, - onColorChanged: (color) { - setState(() { - OpenEarableSettings().selectedColor = color; - }); - }, - showLabel: true, - pickerAreaHeightPercent: 0.8, - enableAlpha: false, + if (Platform.isAndroid) { + showDialog( + context: context, + builder: (BuildContext context) { + return Material( + child: AlertDialog( + title: const Text('Pick a color for the RGB LED'), + content: SingleChildScrollView( + child: ColorPicker( + pickerColor: OpenEarableSettings().selectedColor, + onColorChanged: (color) { + setState(() { + OpenEarableSettings().selectedColor = color; + }); + }, + showLabel: true, + pickerAreaHeightPercent: 0.8, + enableAlpha: false, + ), ), - ), - actions: [ - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Done'), - ), - ], - ); - }, - ); + actions: [ + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Done'), + ), + ], + )); + }, + ); + } else { + showCupertinoDialog( + context: context, + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: Text('Pick a color for the RGB LED'), + content: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Theme( + data: materialTheme, + child: Material( + // Wrap with Material + child: Localizations( + locale: + const Locale('en', 'US'), // Specify the app's locale + delegates: [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + child: ColorPicker( + pickerColor: OpenEarableSettings().selectedColor, + onColorChanged: (color) { + // Your color change logic + setState(() { + OpenEarableSettings().selectedColor = color; + }); + }, + showLabel: true, + pickerAreaHeightPercent: 0.8, + enableAlpha: false, + ), // Your widget that contains the DropdownButton + ), + ), + )), + actions: [ + CupertinoDialogAction( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text('Done'), + ), + ], + ); + }, + ); + } } @override @@ -149,77 +204,119 @@ class _LEDColorCardState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 5.0), child: Card( - //LED Color Picker Card - color: Theme.of(context).colorScheme.primary, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'LED Color', - style: TextStyle( - color: Colors.white, - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - ), - Row( + //LED Color Picker Card + color: Platform.isIOS + ? CupertinoTheme.of(context).primaryContrastingColor + : Theme.of(context).colorScheme.primary, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: _openEarable.bleManager.connected - ? _openColorPicker - : null, // Open color picker - child: Container( - width: 66, - height: 36, - decoration: BoxDecoration( - color: OpenEarableSettings().selectedColor, - borderRadius: BorderRadius.circular(5), - ), + Text( + 'LED Color', + style: TextStyle( + color: Colors.white, + fontSize: 18.0, + fontWeight: FontWeight.bold, ), ), - SizedBox(width: 5), - SizedBox( - width: 66, - child: ElevatedButton( - onPressed: _openEarable.bleManager.connected - ? _setLEDColor - : null, - style: ElevatedButton.styleFrom( - backgroundColor: Color( - 0xff53515b), // Set the background color to grey - foregroundColor: Colors.white), - child: Text('Set'), + Selector( + selector: (_, bleController) => bleController.connected, + builder: (context, connected, child) => Row( + children: [ + GestureDetector( + onTap: connected + ? _openColorPicker + : null, // Open color picker + child: Container( + width: 66, + height: 36, + decoration: BoxDecoration( + color: OpenEarableSettings().selectedColor, + borderRadius: BorderRadius.circular(5), + ), + ), + ), + SizedBox(width: 5), + SizedBox( + width: 66, + height: 36, + child: Platform.isIOS + ? CupertinoButton( + padding: EdgeInsets.zero, + child: Text('Set', + style: TextStyle( + color: connected + ? Colors.white + : null)), + onPressed: _openEarable.bleManager.connected + ? _setLEDColor + : null, + color: Color(0xff53515b), + ) + : ElevatedButton( + onPressed: _openEarable.bleManager.connected + ? _setLEDColor + : null, + style: ElevatedButton.styleFrom( + backgroundColor: Color( + 0xff53515b), // Set the background color to grey + foregroundColor: Colors.white), + child: Text('Set'), + ), + ), + SizedBox(width: 5), + SizedBox( + width: 66, + height: 36, + child: Platform.isIOS + ? CupertinoButton( + onPressed: + _openEarable.bleManager.connected + ? _startRainbowMode + : null, + color: Color(0xff53515b), + padding: EdgeInsets.zero, + child: Text("🦄")) + : ElevatedButton( + onPressed: + _openEarable.bleManager.connected + ? _startRainbowMode + : null, + style: ElevatedButton.styleFrom( + backgroundColor: Color( + 0xff53515b), // Set the background color to grey + foregroundColor: Colors.white), + child: Text("🦄"), + )), + Spacer(), + SizedBox( + width: 66, + height: 36, + child: Platform.isIOS + ? CupertinoButton( + onPressed: _openEarable.bleManager.connected + ? _turnLEDoff + : null, + padding: EdgeInsets.zero, + color: Color(0xfff27777), + child: Text('Off')) + : ElevatedButton( + onPressed: _openEarable.bleManager.connected + ? _turnLEDoff + : null, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xfff27777), + foregroundColor: Colors.black, + ), + child: Text('Off'), + ), + ) + ], ), ), - SizedBox(width: 5), - ElevatedButton( - onPressed: _openEarable.bleManager.connected - ? _startRainbowMode - : null, - style: ElevatedButton.styleFrom( - backgroundColor: Color( - 0xff53515b), // Set the background color to grey - foregroundColor: Colors.white), - child: Text("🦄"), - ), - Spacer(), - ElevatedButton( - onPressed: _openEarable.bleManager.connected - ? _turnLEDoff - : null, - style: ElevatedButton.styleFrom( - backgroundColor: Color(0xfff27777), - foregroundColor: Colors.black, - ), - child: Text('Off'), - ), - ], - ), - ], - ), - ), - )); + ]), + ))); } } diff --git a/open_earable/lib/controls_tab/views/sensor_configuration.dart b/open_earable/lib/controls_tab/views/sensor_configuration.dart index eee6266..88f10ec 100644 --- a/open_earable/lib/controls_tab/views/sensor_configuration.dart +++ b/open_earable/lib/controls_tab/views/sensor_configuration.dart @@ -1,6 +1,10 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:open_earable/shared/dynamic_value_picker.dart'; +import 'dart:io'; +import 'package:open_earable/ble/ble_controller.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; +import 'package:provider/provider.dart'; import '../models/open_earable_settings.dart'; class SensorConfigurationCard extends StatefulWidget { @@ -98,7 +102,9 @@ class _SensorConfigurationCardState extends State { padding: const EdgeInsets.symmetric(horizontal: 5.0), child: Card( //Audio Player Card - color: Theme.of(context).colorScheme.primary, + color: Platform.isIOS + ? CupertinoTheme.of(context).primaryContrastingColor + : Theme.of(context).colorScheme.primary, child: Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -156,20 +162,42 @@ class _SensorConfigurationCardState extends State { children: [ Expanded( child: SizedBox( - height: 37.0, - child: ElevatedButton( - onPressed: _openEarable.bleManager.connected - ? _writeSensorConfigs - : null, - style: ElevatedButton.styleFrom( - backgroundColor: _openEarable.bleManager.connected - ? Theme.of(context).colorScheme.secondary - : Colors.grey, - foregroundColor: Colors.black, - enableFeedback: _openEarable.bleManager.connected, - ), - child: Text("Set Configuration"), - ), + height: 37, + child: Platform.isIOS + ? CupertinoButton( + padding: EdgeInsets.zero, + onPressed: + Provider.of(context) + .connected + ? () => _writeSensorConfigs() + : null, + color: Provider.of(context) + .connected + ? CupertinoTheme.of(context).primaryColor + : Colors.grey, + child: Text("Set Configuration"), + ) + : ElevatedButton( + onPressed: + Provider.of(context) + .connected + ? _writeSensorConfigs + : null, + style: ElevatedButton.styleFrom( + backgroundColor: + Provider.of(context) + .connected + ? Theme.of(context) + .colorScheme + .secondary + : Colors.grey, + foregroundColor: Colors.black, + enableFeedback: + Provider.of(context) + .connected, + ), + child: Text("Set Configuration"), + ), ), ), ], @@ -190,67 +218,54 @@ class _SensorConfigurationCardState extends State { Function(String) changeSelection) { return Row( children: [ - Checkbox( - checkColor: Theme.of(context).colorScheme.primary, - fillColor: MaterialStateProperty.resolveWith(_getCheckboxColor), - value: settingSelected, - onChanged: _openEarable.bleManager.connected ? changeBool : null, + Platform.isIOS + ? CupertinoCheckbox( + value: settingSelected, + onChanged: Provider.of(context).connected + ? changeBool + : null, + activeColor: settingSelected + ? CupertinoTheme.of(context).primaryColor + : CupertinoTheme.of(context).primaryContrastingColor, + checkColor: CupertinoTheme.of(context).primaryContrastingColor, + ) + : Checkbox( + checkColor: Theme.of(context).colorScheme.primary, + fillColor: MaterialStateProperty.resolveWith(_getCheckboxColor), + value: settingSelected, + onChanged: Provider.of(context).connected + ? changeBool + : null, + ), + Text( + sensorName, + style: TextStyle( + color: Color.fromRGBO(168, 168, 172, 1.0), + ), ), - Text(sensorName), Spacer(), Container( decoration: BoxDecoration( - color: _openEarable.bleManager.connected + color: Provider.of(context).connected ? Colors.white : Colors.grey[200], borderRadius: BorderRadius.circular(4.0), ), child: SizedBox( - height: 37, width: 100, + height: 37, child: Container( alignment: Alignment.centerRight, - child: DropdownButton( - dropdownColor: _openEarable.bleManager.connected - ? Colors.white - : Colors.grey[200], - alignment: Alignment.centerRight, - value: currentValue, - onChanged: (String? newValue) { - setState(() { - changeSelection(newValue!); - if (int.parse(newValue) != 0) { - changeBool(true); - } else { - changeBool(false); - } - }); - }, - items: options.map((String value) { - return DropdownMenuItem( - alignment: Alignment.centerRight, - value: value, - child: Text( - value, - style: TextStyle( - color: _openEarable.bleManager.connected - ? Colors.black - : Colors.grey, - ), - textAlign: TextAlign.end, - ), - ); - }).toList(), - underline: Container(), - icon: Icon( - Icons.arrow_drop_down, - color: _openEarable.bleManager.connected - ? Colors.black - : Colors.grey, - ), + child: DynamicValuePicker( + context, + options, + currentValue, + changeSelection, + changeBool, + Provider.of(context).connected, )))), SizedBox(width: 8), - Text("Hz"), + Text("Hz", style: TextStyle(color: Color.fromRGBO(168, 168, 172, 1.0))), ], ); } diff --git a/open_earable/lib/main.dart b/open_earable/lib/main.dart index 55f4f11..d229b60 100644 --- a/open_earable/lib/main.dart +++ b/open_earable/lib/main.dart @@ -1,44 +1,49 @@ +import 'dart:io'; import 'dart:async'; - -import 'package:provider/provider.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:open_earable/open_earable_icon_icons.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:open_earable/shared/open_earable_icon_icons.dart'; +import 'package:provider/provider.dart'; import 'controls_tab/controls_tab.dart'; import 'sensor_data_tab/sensor_data_tab.dart'; -import 'ble.dart'; -import 'apps_tab.dart'; +import 'package:open_earable/ble/ble_connect_view.dart'; +import 'package:open_earable/ble/ble_controller.dart'; +import 'apps_tab/apps_tab.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:open_earable/controls_tab/models/open_earable_settings.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'package:app_settings/app_settings.dart'; +import 'shared/global_theme.dart'; -void main() => runApp(MyApp()); +void main() => runApp(ChangeNotifierProvider( + create: (context) => BluetoothController(), child: MyApp())); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - title: '🦻 OpenEarable', - theme: ThemeData( - useMaterial3: false, - colorScheme: ColorScheme( - brightness: Brightness.dark, - primary: Color.fromARGB( - 255, 54, 53, 59), //Color.fromARGB(255, 22, 22, 24) - onPrimary: Colors.white, - secondary: Color.fromARGB(255, 119, 242, 161), - onSecondary: Colors.white, - error: Colors.red, - onError: Colors.black, - background: Color.fromARGB( - 255, 22, 22, 24), //Color.fromARGB(255, 54, 53, 59) - onBackground: Colors.white, - surface: Color.fromARGB(255, 22, 22, 24), - onSurface: Colors.white), - secondaryHeaderColor: Colors.black), - home: MyHomePage(), - ); + if (Platform.isIOS) { + return CupertinoApp( + locale: const Locale('en', 'US'), + localizationsDelegates: [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: [ + const Locale('en', 'US'), + ], + title: '🦻 OpenEarable', + theme: cupertinoTheme, + home: MyHomePage(), + ); + } else { + return MaterialApp( + title: '🦻 OpenEarable', + theme: materialTheme, + home: MyHomePage(), + ); + } } } @@ -60,9 +65,13 @@ class _MyHomePageState extends State { alertOpen = false; _checkBLEPermission(); _openEarable = OpenEarable(); + Provider.of(context, listen: false).openEarable = + _openEarable; _widgetOptions = [ ControlTab(_openEarable), - SensorDataTab(_openEarable), + Material( + child: + Theme(data: materialTheme, child: SensorDataTab(_openEarable))), AppsTab(_openEarable), ]; } @@ -92,40 +101,77 @@ class _MyHomePageState extends State { } void _showBluetoothAlert(BuildContext context) { - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) { - return AlertDialog( - title: Text("Bluetooth disabled"), - content: Text( - "Please make sure your device's bluetooth and location services are turned on and this app has been granted permission to use them in the app's settings.\nThis alert can only be closed if these requirements are fulfilled."), - actions: [ - TextButton( - child: Text( - 'Open App Settings', - style: TextStyle( - color: Theme.of(context).colorScheme.onBackground), - ), - onPressed: () { - AppSettings.openAppSettings(); - }, - ), - TextButton( - child: Text('OK', + if (Platform.isIOS) { + showCupertinoModalPopup( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => CupertinoTheme( + data: CupertinoThemeData(), + child: CupertinoAlertDialog( + title: const Text('Bluetooth disabled'), + content: const Text( + "Please make sure your device's bluetooth and location services are turned on and this app has been granted permission to use them in the app's settings.\nThis alert can only be closed if these requirements are fulfilled."), + actions: [ + CupertinoDialogAction( + isDefaultAction: false, + onPressed: () { + AppSettings.openAppSettings(); + }, + child: Text( + 'Open App Settings', + ), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + if (flutterReactiveBle.status == BleStatus.ready) { + alertOpen = false; + Navigator.of(context).pop(); + } + }, + child: Text( + 'OK', + ), + ), + ], + ), + )); + } else { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: Text("Bluetooth disabled"), + content: Text( + "Please make sure your device's bluetooth and location services are turned on and this app has been granted permission to use them in the app's settings.\nThis alert can only be closed if these requirements are fulfilled."), + actions: [ + TextButton( + child: Text( + 'Open App Settings', style: TextStyle( - color: Theme.of(context).colorScheme.onBackground)), - onPressed: () { - if (flutterReactiveBle.status == BleStatus.ready) { - alertOpen = false; - Navigator.of(context).pop(); - } - }, - ), - ], - ); - }, - ); + color: Theme.of(context).colorScheme.onBackground), + ), + onPressed: () { + AppSettings.openAppSettings(); + }, + ), + TextButton( + child: Text('OK', + style: TextStyle( + color: Theme.of(context).colorScheme.onBackground)), + onPressed: () { + if (flutterReactiveBle.status == BleStatus.ready) { + alertOpen = false; + Navigator.of(context).pop(); + } + }, + ), + ], + ); + }, + ); + } } void _onItemTapped(int index) { @@ -136,62 +182,117 @@ class _MyHomePageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.background, - appBar: AppBar( - title: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(width: 50), - Image.asset( - 'assets/earable_logo.png', // Replace with your image path - width: 24, // Adjust the width as needed - height: 24, // Adjust the height as needed + if (Platform.isIOS) { + return CupertinoTabScaffold( + tabBar: CupertinoTabBar( + backgroundColor: CupertinoTheme.of(context).scaffoldBackgroundColor, + items: [ + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.gear), + label: 'Controls', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.heart), + label: 'Sensor Data', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.square_grid_2x2), + label: 'Apps', ), - SizedBox(width: 8), // Add spacing between the image and text - Text('OpenEarable'), ], - )), - actions: [ - IconButton( - icon: Icon(Icons.bluetooth, - color: Theme.of(context).colorScheme.secondary), - onPressed: () { - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => BLEPage(_openEarable))); - }, - ), - ], - ), - body: _widgetOptions.elementAt(_selectedIndex), - bottomNavigationBar: BottomNavigationBar( + currentIndex: _selectedIndex, + onTap: _onItemTapped, + ), + tabBuilder: (BuildContext context, int index) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/earable_logo.png', + width: 24, + height: 24, + ), + SizedBox(width: 8), + Text('OpenEarable'), + ], + ), + trailing: CupertinoButton( + padding: EdgeInsets.zero, + child: Icon( + CupertinoIcons.bluetooth, + color: CupertinoTheme.of(context).primaryColor, + ), + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) => BLEPage(_openEarable)), + ); + }, + ), + ), + backgroundColor: CupertinoTheme.of(context).scaffoldBackgroundColor, + child: _widgetOptions.elementAt(index), + ); + }, + ); + } else { + return Scaffold( backgroundColor: Theme.of(context).colorScheme.background, - items: const [ - BottomNavigationBarItem( - icon: Padding( - padding: EdgeInsets.only( - bottom: - 3.0), // Adjust the bottom padding to your desired value - child: Icon( - OpenEarableIcon.icon, - size: 20.0, // Change the size to your desired value + appBar: AppBar( + title: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(width: 50), + Image.asset( + 'assets/earable_logo.png', + width: 24, + height: 24, ), + SizedBox(width: 8), + Text('OpenEarable'), + ], + )), + actions: [ + IconButton( + icon: Icon(Icons.bluetooth, + color: Theme.of(context).colorScheme.secondary), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => BLEPage(_openEarable))); + }, ), - label: 'Controls', - ), - BottomNavigationBarItem( - icon: Icon(Icons.monitor_heart_outlined), - label: 'Sensor Data', - ), - BottomNavigationBarItem( - icon: Icon(Icons.apps), - label: 'Apps', - ), - ], - currentIndex: _selectedIndex, - onTap: _onItemTapped, - ), - ); + ], + ), + body: _widgetOptions.elementAt(_selectedIndex), + bottomNavigationBar: BottomNavigationBar( + backgroundColor: Theme.of(context).colorScheme.background, + items: const [ + BottomNavigationBarItem( + icon: Padding( + padding: EdgeInsets.only(bottom: 3.0), + child: Icon( + OpenEarableIcon.icon, + size: 20.0, + ), + ), + label: 'Controls', + ), + BottomNavigationBarItem( + icon: Icon(Icons.monitor_heart_outlined), + label: 'Sensor Data', + ), + BottomNavigationBarItem( + icon: Icon(Icons.apps), + label: 'Apps', + ), + ], + currentIndex: _selectedIndex, + onTap: _onItemTapped, + ), + ); + } } } diff --git a/open_earable/lib/sensor_data_tab/earable_3d_model.dart b/open_earable/lib/sensor_data_tab/earable_3d_model.dart index 5730cf3..3ad4dc0 100644 --- a/open_earable/lib/sensor_data_tab/earable_3d_model.dart +++ b/open_earable/lib/sensor_data_tab/earable_3d_model.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:open_earable/shared/earable_not_connected_warning.dart'; +import 'package:provider/provider.dart'; import 'dart:async'; import 'package:three_dart/three_dart.dart' as three; import 'package:three_dart_jsm/three_dart_jsm.dart' as three_jsm; @@ -6,6 +8,7 @@ import 'package:flutter_gl/flutter_gl.dart'; import 'package:three_dart/three3d/math/euler.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; import 'dart:math'; +import 'package:open_earable/ble/ble_controller.dart'; class Earable3DModel extends StatefulWidget { final OpenEarable _openEarable; @@ -88,52 +91,57 @@ class _Earable3DModelState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - // child: Text(title, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - ), - Expanded(child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - width = constraints.maxWidth; - height = constraints.maxHeight; - Color c = Theme.of(context).colorScheme.background; - _sceneBackground = three.Color.fromArray([c.red, c.green, c.blue]); - initSize(context); - return Column( - children: [ - Stack( - children: [ - Container( - width: width, - height: height, - color: Theme.of(context).colorScheme.background, - child: Builder(builder: (BuildContext context) { - if (kIsWeb) { - return three3dRender.isInitialized - ? HtmlElementView( - viewType: - three3dRender.textureId!.toString()) - : Container(); - } else { - return three3dRender.isInitialized - ? Texture(textureId: three3dRender.textureId!) - : Container(); - } - })), - ], - ), - ], - ); - }, - )), - Padding( - padding: EdgeInsets.only(bottom: 16), - child: Text( - "Yaw: ${(_yaw * 180 / pi).toStringAsFixed(1)}°\nPitch: ${(_pitch * 180 / pi).toStringAsFixed(1)}°\nRoll: ${(_roll * 180 / pi).toStringAsFixed(1)}°")) - ], - ); + if (!Provider.of(context).connected) { + return EarableNotConnectedWarning(); + } else { + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + // child: Text(title, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + ), + Expanded(child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + width = constraints.maxWidth; + height = constraints.maxHeight; + Color c = Theme.of(context).colorScheme.background; + _sceneBackground = + three.Color.fromArray([c.red, c.green, c.blue]); + initSize(context); + return Column( + children: [ + Stack( + children: [ + Container( + width: width, + height: height, + color: Theme.of(context).colorScheme.background, + child: Builder(builder: (BuildContext context) { + if (kIsWeb) { + return three3dRender.isInitialized + ? HtmlElementView( + viewType: + three3dRender.textureId!.toString()) + : Container(); + } else { + return three3dRender.isInitialized + ? Texture(textureId: three3dRender.textureId!) + : Container(); + } + })), + ], + ), + ], + ); + }, + )), + Padding( + padding: EdgeInsets.only(bottom: 16), + child: Text( + "Yaw: ${(_yaw * 180 / pi).toStringAsFixed(1)}°\nPitch: ${(_pitch * 180 / pi).toStringAsFixed(1)}°\nRoll: ${(_roll * 180 / pi).toStringAsFixed(1)}°")) + ], + ); + } } // Platform messages are asynchronous, so we initialize in an async method. diff --git a/open_earable/lib/sensor_data_tab/sensor_chart.dart b/open_earable/lib/sensor_data_tab/sensor_chart.dart index 9327df4..3f0c331 100644 --- a/open_earable/lib/sensor_data_tab/sensor_chart.dart +++ b/open_earable/lib/sensor_data_tab/sensor_chart.dart @@ -1,9 +1,11 @@ import 'dart:async'; -import 'package:flutter/scheduler.dart'; +import 'package:open_earable/ble/ble_controller.dart'; +import 'package:open_earable/shared/earable_not_connected_warning.dart'; 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:provider/provider.dart'; import 'package:simple_kalman/simple_kalman.dart'; import 'package:collection/collection.dart'; import 'dart:math'; @@ -34,6 +36,13 @@ class _EarableDataChartState extends State { late SimpleKalman kalmanX, kalmanY, kalmanZ; late String _sensorName; int _numDatapoints = 200; + Map _units = { + "Accelerometer": "m/s\u00B2", + "Gyroscope": "°/s", + "Magnetometer": "µT", + "Pressure": "Pa", + "Temperature": "°C" + }; _setupListeners() { if (_title == "Pressure" || _title == "Temperature") { _dataSubscription = @@ -85,17 +94,19 @@ class _EarableDataChartState extends State { _checkLength(_data); SensorData? maxXYZValue = maxBy(_data, (SensorData b) => b.getMax()); SensorData? minXYZValue = minBy(_data, (SensorData b) => b.getMin()); + if (maxXYZValue == null || minXYZValue == null) { return; } - double maxAbsValue = - max(maxXYZValue.getMax().abs(), minXYZValue.getMin().abs()); + double maxY = maxXYZValue!.getMax(); + double minY = minXYZValue!.getMin(); + double maxAbsValue = max(maxY.abs(), minY.abs()); _maxY = (_title == "Pressure" || _title == "Temperature") - ? max(0, maxXYZValue.getMax()) + ? maxY : maxAbsValue; _minY = (_title == "Pressure" || _title == "Temperature") - ? min(0, minXYZValue.getMin()) + ? minY : -maxAbsValue; _maxX = value.timestamp; _minX = _data[0].timestamp; @@ -146,7 +157,9 @@ class _EarableDataChartState extends State { _minY = -25; _maxY = 25; } - _setupListeners(); + if (_openEarable.bleManager.connected) { + _setupListeners(); + } } @override @@ -163,10 +176,13 @@ class _EarableDataChartState extends State { @override Widget build(BuildContext context) { + if (!Provider.of(context).connected) { + return EarableNotConnectedWarning(); + } if (_title == 'Pressure' || _title == 'Temperature') { seriesList = [ charts.Series( - id: '$_title${_data.isNotEmpty ? " (${_data[0].units[_title]})" : ""}', + id: '$_title${_data.isNotEmpty ? " (${_units[_title]})" : ""}', colorFn: (_, __) => charts.Color.fromHex(code: colors[0]), domainFn: (SensorData data, _) => data.timestamp, measureFn: (SensorData data, _) => data.values[0], @@ -176,21 +192,21 @@ class _EarableDataChartState extends State { } else { seriesList = [ charts.Series( - id: 'X${_data.isNotEmpty ? " (${_data[0].units['X']})" : ""}', + id: 'X${_data.isNotEmpty ? " (${_units[_title]})" : ""}', colorFn: (_, __) => charts.Color.fromHex(code: colors[0]), domainFn: (SensorData data, _) => data.timestamp, measureFn: (SensorData data, _) => data.values[0], data: _data, ), charts.Series( - id: 'Y${_data.isNotEmpty ? " (${_data[0].units['Y']})" : ""}', + id: 'Y${_data.isNotEmpty ? " (${_units[_title]})" : ""}', colorFn: (_, __) => charts.Color.fromHex(code: colors[1]), domainFn: (SensorData data, _) => data.timestamp, measureFn: (SensorData data, _) => data.values[1], data: _data, ), charts.Series( - id: 'Z${_data.isNotEmpty ? " (${_data[0].units['Z']})" : ""}', + id: 'Z${_data.isNotEmpty ? " (${_units[_title]})" : ""}', colorFn: (_, __) => charts.Color.fromHex(code: colors[2]), domainFn: (SensorData data, _) => data.timestamp, measureFn: (SensorData data, _) => data.values[2], @@ -225,8 +241,10 @@ class _EarableDataChartState extends State { ) ], primaryMeasureAxis: charts.NumericAxisSpec( - tickProviderSpec: - charts.BasicNumericTickProviderSpec(desiredTickCount: 7), + tickProviderSpec: charts.BasicNumericTickProviderSpec( + desiredTickCount: 7, + zeroBound: false, + dataIsInWholeNumbers: false), renderSpec: charts.GridlineRendererSpec( labelStyle: charts.TextStyleSpec( fontSize: 14, diff --git a/open_earable/lib/sensor_data_tab/sensor_data_tab.dart b/open_earable/lib/sensor_data_tab/sensor_data_tab.dart index b89b53e..83f08c5 100644 --- a/open_earable/lib/sensor_data_tab/sensor_data_tab.dart +++ b/open_earable/lib/sensor_data_tab/sensor_data_tab.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:open_earable/sensor_data_tab/earable_3d_model.dart'; -import 'package:open_earable/widgets/earable_not_connected_warning.dart'; import 'package:open_earable_flutter/src/open_earable_flutter.dart'; import 'package:open_earable/sensor_data_tab/sensor_chart.dart'; @@ -58,14 +57,6 @@ class _SensorDataTabState extends State @override Widget build(BuildContext context) { - if (!_openEarable.bleManager.connected) { - return EarableNotConnectedWarning(); - } else { - return _buildSensorDataTabs(); - } - } - - Widget _buildSensorDataTabs() { return Scaffold( backgroundColor: Theme.of(context).colorScheme.background, appBar: PreferredSize( diff --git a/open_earable/lib/shared/dynamic_value_picker.dart b/open_earable/lib/shared/dynamic_value_picker.dart new file mode 100644 index 0000000..1fc615a --- /dev/null +++ b/open_earable/lib/shared/dynamic_value_picker.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'dart:io'; + +class DynamicValuePicker extends StatelessWidget { + final BuildContext context; + final List options; + final String currentValue; + final Function(String) onValueChange; + final Function(bool) onValueNotZero; + final bool isConnected; + + DynamicValuePicker( + this.context, + this.options, + this.currentValue, + this.onValueChange, + this.onValueNotZero, + this.isConnected, + ); + + @override + Widget build(BuildContext context) { + if (Platform.isIOS) { + return CupertinoButton( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + color: Colors.white, + padding: EdgeInsets.fromLTRB(8, 0, 0, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + currentValue, + style: TextStyle( + color: isConnected ? Colors.black : Colors.grey, + ), + ), + ], + ), + onPressed: () => _showCupertinoPicker(), + ); + } else { + return DropdownButton( + dropdownColor: isConnected ? Colors.white : Colors.grey[200], + alignment: Alignment.centerRight, + value: currentValue, + onChanged: (String? newValue) { + onValueChange(newValue!); + if (int.parse(newValue) != 0) { + onValueNotZero(true); + } else { + onValueNotZero(false); + } + }, + items: options.map((String value) { + return DropdownMenuItem( + alignment: Alignment.centerRight, + value: value, + child: Text( + value, + style: TextStyle( + color: isConnected ? Colors.black : Colors.grey, + ), + textAlign: TextAlign.end, + ), + ); + }).toList(), + underline: Container(), + icon: Icon( + Icons.arrow_drop_down, + color: isConnected ? Colors.black : Colors.grey, + ), + ); + } + } + + void _showCupertinoPicker() { + var currentIndex = options.indexOf(currentValue); + final FixedExtentScrollController scrollController = + FixedExtentScrollController(initialItem: currentIndex); + showCupertinoModalPopup( + context: context, + builder: (_) => Container( + height: 200, + color: Colors.white, + child: CupertinoPicker( + scrollController: scrollController, + backgroundColor: isConnected ? Colors.white : Colors.grey[200], + itemExtent: 32, // Height of each item + onSelectedItemChanged: (int index) { + String newValue = options[index]; + int? newValueInt = int.tryParse(newValue); + onValueChange(newValue); + if (newValueInt != 0) { + onValueNotZero(true); + } else { + onValueNotZero(false); + } + }, + children: options + .map((String value) => Center( + child: Text( + value, + style: TextStyle( + color: isConnected ? Colors.black : Colors.grey, + ), + ), + )) + .toList(), + ), + ), + ); + } +} diff --git a/open_earable/lib/widgets/earable_not_connected_warning.dart b/open_earable/lib/shared/earable_not_connected_warning.dart similarity index 100% rename from open_earable/lib/widgets/earable_not_connected_warning.dart rename to open_earable/lib/shared/earable_not_connected_warning.dart diff --git a/open_earable/lib/shared/global_theme.dart b/open_earable/lib/shared/global_theme.dart new file mode 100644 index 0000000..6542a42 --- /dev/null +++ b/open_earable/lib/shared/global_theme.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; + +final ThemeData materialTheme = ThemeData( + useMaterial3: false, + colorScheme: ColorScheme( + brightness: Brightness.dark, + primary: Color.fromARGB(255, 54, 53, 59), + onPrimary: Colors.white, + secondary: Color.fromARGB(255, 119, 242, 161), + onSecondary: Colors.white, + error: Colors.red, + onError: Colors.black, + background: Color.fromARGB(255, 22, 22, 24), + onBackground: Colors.white, + surface: Color.fromARGB(255, 22, 22, 24), + onSurface: Colors.white, + ), + secondaryHeaderColor: Colors.black, +); + +final CupertinoThemeData cupertinoTheme = CupertinoThemeData( + brightness: Brightness.dark, + primaryColor: Color.fromARGB(255, 119, 242, 161), + primaryContrastingColor: Color.fromARGB(255, 54, 53, 59), + barBackgroundColor: Color.fromARGB(255, 22, 22, 24), + scaffoldBackgroundColor: Color.fromARGB(255, 22, 22, 24), + textTheme: CupertinoTextThemeData( + primaryColor: Colors.white, + ), +); diff --git a/open_earable/lib/open_earable_icon_icons.dart b/open_earable/lib/shared/open_earable_icon_icons.dart similarity index 100% rename from open_earable/lib/open_earable_icon_icons.dart rename to open_earable/lib/shared/open_earable_icon_icons.dart diff --git a/open_earable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_earable/macos/Flutter/GeneratedPluginRegistrant.swift index 2b64fea..1478b26 100644 --- a/open_earable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_earable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import flutter_gl_macos import path_provider_foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterGlMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterGlMacosPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/open_earable/macos/Runner.xcodeproj/project.pbxproj b/open_earable/macos/Runner.xcodeproj/project.pbxproj index 843b5e3..84a9d1b 100644 --- a/open_earable/macos/Runner.xcodeproj/project.pbxproj +++ b/open_earable/macos/Runner.xcodeproj/project.pbxproj @@ -227,7 +227,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 331C80D4294CF70F00263BE5 = { diff --git a/open_earable/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/open_earable/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a028d7a..7722b11 100644 --- a/open_earable/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/open_earable/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ =3.2.0-194.0.dev <4.0.0" + dart: ">=3.2.0 <4.0.0" flutter: ">=3.16.0" diff --git a/open_earable/pubspec.yaml b/open_earable/pubspec.yaml index f709a98..2b8c5d6 100644 --- a/open_earable/pubspec.yaml +++ b/open_earable/pubspec.yaml @@ -30,9 +30,12 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter open_earable_flutter: git: url: https://github.com/OpenEarable/open_earable_flutter.git + youtube_player_flutter: ^8.1.2 permission_handler: ^11.1.0 flutter_reactive_ble: ^5.2.0 app_settings: ^5.1.1 @@ -40,7 +43,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 - + english_words: ^4.0.0 ditredi: ^2.0.0 vector_math: ^2.1.4 @@ -64,7 +67,11 @@ dependencies: path_provider: ^2.1.1 open_file: ^3.3.2 provider: ^6.1.1 - + shared_preferences: ^2.2.2 + +dependency_overrides: + intl: ^0.18.1 + dev_dependencies: flutter_test: sdk: flutter @@ -124,7 +131,14 @@ flutter: # - images/a_dot_ham.jpeg assets: - assets/ - - assets/posture_tracker/ + - lib/apps_tab/powernapper/assets/ + - lib/apps_tab/recorder/assets/ + - lib/apps_tab/posture_tracker/assets/ + - lib/apps_tab/neck_stretch/assets/ + - lib/apps_tab/jump_height_test/assets/ + - lib/apps_tab/tightness/assets/ + - lib/apps_tab/jump_rope_counter/assets/ + - lib/apps_tab/step_counter/assets/ fonts: - family: OpenEarableIcon diff --git a/open_earable/windows/flutter/CMakeLists.txt b/open_earable/windows/flutter/CMakeLists.txt index 930d207..903f489 100644 --- a/open_earable/windows/flutter/CMakeLists.txt +++ b/open_earable/windows/flutter/CMakeLists.txt @@ -10,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/open_earable/windows/runner/flutter_window.cpp b/open_earable/windows/runner/flutter_window.cpp index b25e363..955ee30 100644 --- a/open_earable/windows/runner/flutter_window.cpp +++ b/open_earable/windows/runner/flutter_window.cpp @@ -31,6 +31,11 @@ bool FlutterWindow::OnCreate() { this->Show(); }); + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + return true; }