diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 0824fec32..0d464dcb2 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,285 +1,311 @@ +## 10.2.0 + +* Using named parameters in most methods for consistency. +* Added a `HealthPlatformType` to save which health platform the data originates from (Apple Health, Google Fit, or Google Health Connect). +* Android: Improved support for Google Health Connect + * getHealthConnectSdkStatus, PR [#941](https://github.com/cph-cachet/flutter-plugins/pull/941) + * installHealthConnect, PR [#943](https://github.com/cph-cachet/flutter-plugins/pull/943) + * workout title, PR [#938](https://github.com/cph-cachet/flutter-plugins/pull/938) +* iOS: Add support for saving blood pressure as a correlation, PR [#919](https://github.com/cph-cachet/flutter-plugins/pull/919) + +## 10.1.1 + +* Fix of error in `WorkoutSummary` JSON serialization. +* Fix of [#934](https://github.com/cph-cachet/flutter-plugins/issues/934) +* Empty value check for calories nutrition, PR [#926](https://github.com/cph-cachet/flutter-plugins/pull/926) + +## 10.0.0 + +* **BREAKING** The plugin now works as a singleton using `Health()` to access it (instead of creating an instance of `HealthFactory`). + * This entails that the plugin now need to be configured using the `configure()` method before use. + * The example app has been update to demonstrate this new singleton model. +* Support for new data types: + * body water mass, PR [#917](https://github.com/cph-cachet/flutter-plugins/pull/917) + * caffeine, PR [#924](https://github.com/cph-cachet/flutter-plugins/pull/924) + * workout summary, manual entry and new health data types, PR [#920](https://github.com/cph-cachet/flutter-plugins/pull/920) +* Fixed `SleepSessionRecord`, PR [#928](https://github.com/cph-cachet/flutter-plugins/pull/928) +* Update to API and README docs +* Upgrade to Dart 3.2 and Flutter 3. +* Added Dart linter and fixed a series of type casting issues. +* Using carp_serializable for consistent camel_case and type-safe generation of JSON serialization methods for polymorphic health data type classes. + ## 9.0.0 -- Updated HC to comply with Android 14, PR [#834](https://github.com/cph-cachet/flutter-plugins/pull/834) and [#882](https://github.com/cph-cachet/flutter-plugins/pull/882) -- Added checks for NullPointerException, closes issue [#878](https://github.com/cph-cachet/flutter-plugins/issues/878) -- Updated intl to ^0.19.0 -- Upgrade to AGP 8, PR [#868](https://github.com/cph-cachet/flutter-plugins/pull/868) -- Added missing google fit workout types, PR [#836](https://github.com/cph-cachet/flutter-plugins/pull/836) -- Added pagination in HC, PR [#862](https://github.com/cph-cachet/flutter-plugins/pull/862) -- Fix of permission in example app + improvements to doc, PR [#875](https://github.com/cph-cachet/flutter-plugins/pull/875) +* Updated HC to comply with Android 14, PR [#834](https://github.com/cph-cachet/flutter-plugins/pull/834) and [#882](https://github.com/cph-cachet/flutter-plugins/pull/882) +* Added checks for NullPointerException, closes issue [#878](https://github.com/cph-cachet/flutter-plugins/issues/878) +* Updated intl to ^0.19.0 +* Upgrade to AGP 8, PR [#868](https://github.com/cph-cachet/flutter-plugins/pull/868) +* Added missing google fit workout types, PR [#836](https://github.com/cph-cachet/flutter-plugins/pull/836) +* Added pagination in HC, PR [#862](https://github.com/cph-cachet/flutter-plugins/pull/862) +* Fix of permission in example app + improvements to doc, PR [#875](https://github.com/cph-cachet/flutter-plugins/pull/875) ## 8.1.0 -- Fixed sleep stages on iOS, Issue [#803](https://github.com/cph-cachet/flutter-plugins/issues/803) -- Added Nutrition data type, includes PR [#679](https://github.com/cph-cachet/flutter-plugins/pull/679) -- Lowered minSDK, Issue [#809](https://github.com/cph-cachet/flutter-plugins/issues/809) +* Fixed sleep stages on iOS, Issue [#803](https://github.com/cph-cachet/flutter-plugins/issues/803) +* Added Nutrition data type, includes PR [#679](https://github.com/cph-cachet/flutter-plugins/pull/679) +* Lowered minSDK, Issue [#809](https://github.com/cph-cachet/flutter-plugins/issues/809) ## 8.0.0 -- Fixed issue [#774](https://github.com/cph-cachet/flutter-plugins/issues/774), [#779](https://github.com/cph-cachet/flutter-plugins/issues/779) -- Merged PR [#579](https://github.com/cph-cachet/flutter-plugins/pull/579), [#717](https://github.com/cph-cachet/flutter-plugins/pull/717), [#770](https://github.com/cph-cachet/flutter-plugins/pull/770) -- Upgraded to mavenCentral, upgraded minSDK, compilSDK, targetSDK -- Updated health connect client to 1.1.0 -- Added respiratory rate and peripheral perfusion index to HealthConnect -- Minor fixes to requestAuthorization, sleep stage filtering - +* Fixed issue [#774](https://github.com/cph-cachet/flutter-plugins/issues/774), [#779](https://github.com/cph-cachet/flutter-plugins/issues/779) +* Merged PR [#579](https://github.com/cph-cachet/flutter-plugins/pull/579), [#717](https://github.com/cph-cachet/flutter-plugins/pull/717), [#770](https://github.com/cph-cachet/flutter-plugins/pull/770) +* Upgraded to mavenCentral, upgraded minSDK, compileSDK, targetSDK +* Updated health connect client to 1.1.0 +* Added respiratory rate and peripheral perfusion index to HealthConnect +* Minor fixes to requestAuthorization, sleep stage filtering ## 7.0.1 -- Updated dart doc +* Updated dart doc ## 7.0.0 -- Merged PR #722 -- Added deep, light, REM, and out of bed sleep to iOS and Android HealthConnect +* Merged PR #722 +* Added deep, light, REM, and out of bed sleep to iOS and Android HealthConnect ## 6.0.0 -- Fixed issues #[694](https://github.com/cph-cachet/flutter-plugins/issues/694), #[696](https://github.com/cph-cachet/flutter-plugins/issues/696), #[697](https://github.com/cph-cachet/flutter-plugins/issues/697), #[698](https://github.com/cph-cachet/flutter-plugins/issues/698) -- added totalSteps for HealthConnect -- added supplemental oxygen flow rate for blood oxygen saturation on Android +* Fixed issues #[694](https://github.com/cph-cachet/flutter-plugins/issues/694), #[696](https://github.com/cph-cachet/flutter-plugins/issues/696), #[697](https://github.com/cph-cachet/flutter-plugins/issues/697), #[698](https://github.com/cph-cachet/flutter-plugins/issues/698) +* added totalSteps for HealthConnect +* added supplemental oxygen flow rate for blood oxygen saturation on Android ## 5.0.0 -- Added initial support for the new Health Connect API, as Google Fit is being deprecated. - - Does not yet support `revokePermissions`, `getTotalStepsInInterval`. -- Changed Intl package version dependancy to `^0.17.0` to work with flutter stable version. -- Updated the example app to handle more buttons. +* Added initial support for the new Health Connect API, as Google Fit is being deprecated. + * Does not yet support `revokePermissions`, `getTotalStepsInInterval`. +* Changed Intl package version dependency to `^0.17.0` to work with flutter stable version. +* Updated the example app to handle more buttons. ## 4.6.0 -- Added method for revoking permissions. On Android it uses `disableFit()` to remove access to Google Fit - `revokePermissions`. Documented lack of methods for iOS. +* Added method for revoking permissions. On Android it uses `disableFit()` to remove access to Google Fit - `revokePermissions`. Documented lack of methods for iOS. ## 4.5.0 -- Updated android sdk, gradle -- Updated `enumToString` to native `.name` -- Update and fixed JSON serialization of HealthDataPoints -- Removed auth request in `writeWorkoutData` to avoid bug when denying the auth. -- Merged pull requests [#653](https://github.com/cph-cachet/flutter-plugins/pull/653), [#652](https://github.com/cph-cachet/flutter-plugins/pull/652), [#639](https://github.com/cph-cachet/flutter-plugins/pull/639), [#644](https://github.com/cph-cachet/flutter-plugins/pull/644), [#668](https://github.com/cph-cachet/flutter-plugins/pull/668) -- Further developed [#644](https://github.com/cph-cachet/flutter-plugins/pull/644) on android to accommodate having the `writeBloodPressure` api. -- Small bug fixes +* Updated android sdk, gradle +* Updated `enumToString` to native `.name` +* Update and fixed JSON serialization of HealthDataPoints +* Removed auth request in `writeWorkoutData` to avoid bug when denying the auth. +* Merged pull requests [#653](https://github.com/cph-cachet/flutter-plugins/pull/653), [#652](https://github.com/cph-cachet/flutter-plugins/pull/652), [#639](https://github.com/cph-cachet/flutter-plugins/pull/639), [#644](https://github.com/cph-cachet/flutter-plugins/pull/644), [#668](https://github.com/cph-cachet/flutter-plugins/pull/668) +* Further developed [#644](https://github.com/cph-cachet/flutter-plugins/pull/644) on android to accommodate having the `writeBloodPressure` api. +* Small bug fixes ## 4.4.0 -- Merged pull request #[566](https://github.com/cph-cachet/flutter-plugins/pull/566), [#578](https://github.com/cph-cachet/flutter-plugins/pull/578), [#596](https://github.com/cph-cachet/flutter-plugins/pull/596), [#623](https://github.com/cph-cachet/flutter-plugins/pull/623), [#632](https://github.com/cph-cachet/flutter-plugins/pull/632) -- ECG added as part of [#566](https://github.com/cph-cachet/flutter-plugins/pull/566) -- Small fixes +* Merged pull request #[566](https://github.com/cph-cachet/flutter-plugins/pull/566), [#578](https://github.com/cph-cachet/flutter-plugins/pull/578), [#596](https://github.com/cph-cachet/flutter-plugins/pull/596), [#623](https://github.com/cph-cachet/flutter-plugins/pull/623), [#632](https://github.com/cph-cachet/flutter-plugins/pull/632) +* ECG added as part of [#566](https://github.com/cph-cachet/flutter-plugins/pull/566) +* Small fixes ## 4.3.0 -- upgrade to `device_info_plus: ^8.0.0` +* upgrade to `device_info_plus: ^8.0.0` ## 4.2.0 -- upgrade to `device_info_plus: ^7.0.0` +* upgrade to `device_info_plus: ^7.0.0` ## 4.1.1 -- fix of [#572](https://github.com/cph-cachet/flutter-plugins/issues/572). +* fix of [#572](https://github.com/cph-cachet/flutter-plugins/issues/572). ## 4.1.0 -- update of `device_info_plus: ^4.0.0` -- upgraded to Dart 2.17 and Flutter 3.0 +* update of `device_info_plus: ^4.0.0` +* upgraded to Dart 2.17 and Flutter 3.0 ## 4.0.0 -- Large refactor of the `HealthDataPoint` value into generic `HealthValue` and added `NumericHealthValue`, `AudiogramHealthValue` and `WorkoutHealthValue` -- Added support for Audiograms with `writeAudiogram` and in `getHealthDataFromTypes` -- Added support for Workouts with `writeWorkout` and in `getHealthDataFromTypes` -- Added all `HealthWorkoutActivityType`s -- Added more `HealthDataUnit` types -- Fix of [#432](https://github.com/cph-cachet/flutter-plugins/issues/532) -- updated documentation in code -- updated documentation in README.md -- updated example app -- cleaned up code -- removed `requestPermissions` as it was essentially a duplicate of `requestAuthorization` +* Large refactor of the `HealthDataPoint` value into generic `HealthValue` and added `NumericHealthValue`, `AudiogramHealthValue` and `WorkoutHealthValue` +* Added support for Audiograms with `writeAudiogram` and in `getHealthDataFromTypes` +* Added support for Workouts with `writeWorkout` and in `getHealthDataFromTypes` +* Added all `HealthWorkoutActivityType`s +* Added more `HealthDataUnit` types +* Fix of [#432](https://github.com/cph-cachet/flutter-plugins/issues/532) +* updated documentation in code +* updated documentation in README.md +* updated example app +* cleaned up code +* removed `requestPermissions` as it was essentially a duplicate of `requestAuthorization` ## 3.4.4 -- Fix of [#500](https://github.com/cph-cachet/flutter-plugins/issues/500). -- Added Headache-types to HealthDataTypes on iOS +* Fix of [#500](https://github.com/cph-cachet/flutter-plugins/issues/500). +* Added Headache-types to HealthDataTypes on iOS ## 3.4.3 -- fix of [#401](https://github.com/cph-cachet/flutter-plugins/issues/401). +* fix of [#401](https://github.com/cph-cachet/flutter-plugins/issues/401). ## 3.4.2 -- Resolved concurrent issues with native threads [PR#483](https://github.com/cph-cachet/flutter-plugins/pull/483). -- Healthkit CategorySample [PR#485](https://github.com/cph-cachet/flutter-plugins/pull/485). -- update of API documentation. +* Resolved concurrent issues with native threads [PR#483](https://github.com/cph-cachet/flutter-plugins/pull/483). +* HealthKit CategorySample [PR#485](https://github.com/cph-cachet/flutter-plugins/pull/485). +* update of API documentation. ## 3.4.0 -- Add sleep in bed to android [PR#457](https://github.com/cph-cachet/flutter-plugins/pull/457). -- Add the android.permission.ACTIVITY_RECOGNITION setup to the README [PR#458](https://github.com/cph-cachet/flutter-plugins/pull/458). -- Fixed (regression) issues with metric and permissions [PR#462](https://github.com/cph-cachet/flutter-plugins/pull/462). -- Get total steps [PR#471](https://github.com/cph-cachet/flutter-plugins/pull/471). -- update of example app to refelct new features. -- update of API documentation. +* Add sleep in bed to android [PR#457](https://github.com/cph-cachet/flutter-plugins/pull/457). +* Add the android.permission.ACTIVITY_RECOGNITION setup to the README [PR#458](https://github.com/cph-cachet/flutter-plugins/pull/458). +* Fixed (regression) issues with metric and permissions [PR#462](https://github.com/cph-cachet/flutter-plugins/pull/462). +* Get total steps [PR#471](https://github.com/cph-cachet/flutter-plugins/pull/471). +* update of example app to reflect new features. +* update of API documentation. ## 3.3.1 -- DISTANCE_DELTA is for Android, not iOS [PR#428](https://github.com/cph-cachet/flutter-plugins/pull/428). -- added missing READ_ACCESS [PR#454](https://github.com/cph-cachet/flutter-plugins/pull/454). +* DISTANCE_DELTA is for Android, not iOS [PR#428](https://github.com/cph-cachet/flutter-plugins/pull/428). +* added missing READ_ACCESS [PR#454](https://github.com/cph-cachet/flutter-plugins/pull/454). ## 3.3.0 -- Write support on Google Fit and HealthKit [PR#430](https://github.com/cph-cachet/flutter-plugins/pull/430). +* Write support on Google Fit and HealthKit [PR#430](https://github.com/cph-cachet/flutter-plugins/pull/430). ## 3.2.1 -- Updated `device_info_plus` version dependency +* Updated `device_info_plus` version dependency ## 3.2.0 -- added simple `HKWorkout` and `ExerciseTime` support [PR#421](https://github.com/cph-cachet/flutter-plugins/pull/421). +* added simple `HKWorkout` and `ExerciseTime` support [PR#421](https://github.com/cph-cachet/flutter-plugins/pull/421). ## 3.1.1+1 -- added functions to request authorization [PR#394](https://github.com/cph-cachet/flutter-plugins/pull/394) +* added functions to request authorization [PR#394](https://github.com/cph-cachet/flutter-plugins/pull/394) ## 3.1.0 -- added sleep data to Android + fix of permissions and initialization [PR#372](https://github.com/cph-cachet/flutter-plugins/pull/372) -- testability of HealthDataPoint [PR#388](https://github.com/cph-cachet/flutter-plugins/pull/388). -- update to using the `device_info_plus` plugin +* added sleep data to Android + fix of permissions and initialization [PR#372](https://github.com/cph-cachet/flutter-plugins/pull/372) +* testability of HealthDataPoint [PR#388](https://github.com/cph-cachet/flutter-plugins/pull/388). +* update to using the `device_info_plus` plugin ## 3.0.6 -- Added two new fields to the `HealthDataPoint` - `SourceId` and `SourceName` and populate when data is read. This allows datapoints to be disambigous and in some cases allows us to get more accurate data. For example the number of steps can be reported from Apple Health and Watch and without source data they are aggregated into just "steps" producing an innacurate result [PR#281](https://github.com/cph-cachet/flutter-plugins/pull/281). +* Added two new fields to the `HealthDataPoint` - `SourceId` and `SourceName` and populate when data is read. This allows data points to be disambiguous and in some cases allows us to get more accurate data. For example the number of steps can be reported from Apple Health and Watch and without source data they are aggregated into just "steps" producing an inaccurate result [PR#281](https://github.com/cph-cachet/flutter-plugins/pull/281). ## 3.0.5 -- Null safety in Dart has been implemented -- The plugin supports the Android v2 embedding +* Null safety in Dart has been implemented +* The plugin supports the Android v2 embedding ## 3.0.4 -- Upgrade to `device_info` version 2.0.0 +* Upgrade to `device_info` version 2.0.0 ## 3.0.3 -- Merged various PRs, mostly smaller fixes +* Merged various PRs, mostly smaller fixes ## 3.0.2 -- Upgrade to `device_info` version 1.0.0 +* Upgrade to `device_info` version 1.0.0 ## 3.0.1+1 -- Bugfix regarding BMI from +* Bugfix regarding BMI from ## 3.0.0 -- Changed the flow for requesting access and reading data - - Access must be requested manually before reading - - This simplifies the data flow and makes it easier to reason about when debugging -- Data read access is no longer checked for each individual type, but rather on the set of types specified. +* Changed the flow for requesting access and reading data + * Access must be requested manually before reading + * This simplifies the data flow and makes it easier to reason about when debugging +* Data read access is no longer checked for each individual type, but rather on the set of types specified. ## 2.0.9 -- Now handles the case when asking for BMI on Android when no height data has been collected. +* Now handles the case when asking for BMI on Android when no height data has been collected. ## 2.0.8 -- Fixed a merge issue which had deleted the data types added in 2.0.4. +* Fixed a merge issue which had deleted the data types added in 2.0.4. ## 2.0.7 -- Fixed a Google sign-in issue, and a type issue on Android () +* Fixed a Google sign-in issue, and a type issue on Android () ## 2.0.6 -- Fixed a Google sign-in issue. () +* Fixed a Google sign-in issue. () ## 2.0.5 -- Now uses 'device_info' rather than 'device_id' for getting device information +* Now uses 'device_info' rather than 'device_id' for getting device information ## 2.0.4+1 -- Static analysis, formatting etc. +* Static analysis, formatting etc. ## 2.0.4 -- Added Sleep data, Water, and Mindfulness. +* Added Sleep data, Water, and Mindfulness. ## 2.0.3 -- The method `requestAuthorization` is now public again. - -## 2.0.2 - -- Updated the API to take a list of types rather than a single type, when requesting health data. +* The method `requestAuthorization` is now public again. ## 2.0.2 -- Updated the API to take a list of types rather than a single type, when requesting health data. +* Updated the API to take a list of types rather than a single type, when requesting health data. ## 2.0.1+1 -- Removed the need for try-catch on the programmer's end +* Removed the need for try-catch on the programmer's end ## 2.0.1 -- Removed UUID and instead introduced a comparison operator +* Removed UUID and instead introduced a comparison operator ## 2.0.0 -- Changed the API substantially to allow for granular Data Type permissions +* Changed the API substantially to allow for granular Data Type permissions ## 1.1.6 Added the following Health Types as per PR #147 -- DISTANCE_WALKING_RUNNING -- FLIGHTS_CLIMBED -- MOVE_MINUTES -- DISTANCE_DELTA +* DISTANCE_WALKING_RUNNING +* FLIGHTS_CLIMBED +* MOVE_MINUTES +* DISTANCE_DELTA ## 1.1.5 -- Fixed an issue with google authorization -- See +* Fixed an issue with google authorization +* See ## 1.1.4 -- Corrected table of units +* Corrected table of units ## 1.1.3 -- Updated table with units +* Updated table with units ## 1.1.2 -- Now supports the data type `HEART_RATE_VARIABILITY_SDNN` on iOS +* Now supports the data type `HEART_RATE_VARIABILITY_SDNN` on iOS ## 1.1.1 -- Fixed issue #88 () +* Fixed issue #88 () ## 1.1.0 -- Introduced UUID to the HealthDataPoint class -- Re-did the example application +* Introduced UUID to the HealthDataPoint class +* Re-did the example application ## 1.0.6 -- Fixed a null-check warning in the obj-c code (issue #87) +* Fixed a null-check warning in the obj-c code (issue #87) ## 1.0.5 -- Updated gradle-wrapper distribution url `gradle-5.4.1-all.zip` -- Updated docs +* Updated gradle-wrapper distribution url `gradle-5.4.1-all.zip` +* Updated docs ## 1.0.2 -- Updated documentation for Android and Google Fit. +* Updated documentation for Android and Google Fit. ## 1.0.1 -- Streamlined DataType units in Flutter. +* Streamlined DataType units in Flutter. diff --git a/packages/health/README.md b/packages/health/README.md index d12707cb7..bc7d1b474 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -14,64 +14,12 @@ The plugin supports: - writing audiograms on iOS using the `writeAudiogram` method. - writing blood pressure data using the `writeBloodPressure` method. - accessing total step counts using the `getTotalStepsInInterval` method. -- cleaning up dublicate data points via the `removeDuplicates` method. +- cleaning up duplicate data points via the `removeDuplicates` method. - removing data of a given type in a selected period of time using the `delete` method. -- Support the future Android API Health Connect. Note that for Android, the target phone **needs** to have [Google Fit](https://www.google.com/fit/) or [Health Connect](https://health.google/health-connect-android/) (which is currently in beta) installed and have access to the internet, otherwise this plugin will not work. -## Data Types - -| **Data Type** | **Unit** | **iOS** | **Android (Google Fit)** | **Android (Health Connect)** | **Comments** | -| --------------------------- | ----------------------- | ------- | ------------------------ |------------------------------| -------------------------------------- | -| ACTIVE_ENERGY_BURNED | CALORIES | yes | yes | yes | | -| BASAL_ENERGY_BURNED | CALORIES | yes | | yes | | -| BLOOD_GLUCOSE | MILLIGRAM_PER_DECILITER | yes | yes | yes | | -| BLOOD_OXYGEN | PERCENTAGE | yes | yes | yes | | -| BLOOD_PRESSURE_DIASTOLIC | MILLIMETER_OF_MERCURY | yes | yes | yes | | -| BLOOD_PRESSURE_SYSTOLIC | MILLIMETER_OF_MERCURY | yes | yes | yes | | -| BODY_FAT_PERCENTAGE | PERCENTAGE | yes | yes | yes | | -| BODY_MASS_INDEX | NO_UNIT | yes | yes | yes | | -| BODY_TEMPERATURE | DEGREE_CELSIUS | yes | yes | yes | | -| ELECTRODERMAL_ACTIVITY | SIEMENS | yes | | | | -| HEART_RATE | BEATS_PER_MINUTE | yes | yes | yes | | -| HEIGHT | METERS | yes | yes | yes | | -| RESTING_HEART_RATE | BEATS_PER_MINUTE | yes | | yes | | -| RESPIRATORY_RATE | RESPIRATIONS_PER_MINUTE | yes | | yes | -| PERIPHERAL_PERFUSION_INDEX | PERCENTAGE | yes | | | -| STEPS | COUNT | yes | yes | yes | | -| WAIST_CIRCUMFERENCE | METERS | yes | | | | -| WALKING_HEART_RATE | BEATS_PER_MINUTE | yes | | | | -| WEIGHT | KILOGRAMS | yes | yes | yes | | -| DISTANCE_WALKING_RUNNING | METERS | yes | | | | -| FLIGHTS_CLIMBED | COUNT | yes | | yes | | -| MOVE_MINUTES | MINUTES | | yes | | | -| DISTANCE_DELTA | METERS | | yes | yes | | -| MINDFULNESS | MINUTES | yes | | | | -| SLEEP_IN_BED | MINUTES | yes | | | | -| SLEEP_ASLEEP | MINUTES | yes | | yes | | -| SLEEP_AWAKE | MINUTES | yes | | yes | | -| SLEEP_DEEP | MINUTES | yes | | yes | | -| SLEEP_LIGHT | MINUTES | | | yes | | -| SLEEP_REM | MINUTES | yes | | yes | | -| SLEEP_OUT_OF_BED | MINUTES | | | yes | | -| SLEEP_SESSION | MINUTES | | | yes | | -| WATER | LITER | yes | yes | yes | | -| EXERCISE_TIME | MINUTES | yes | | | | -| WORKOUT | NO_UNIT | yes | yes | yes | (Has other workout types) | -| HIGH_HEART_RATE_EVENT | NO_UNIT | yes | | | Requires Apple Watch to write the data | -| LOW_HEART_RATE_EVENT | NO_UNIT | yes | | | Requires Apple Watch to write the data | -| IRREGULAR_HEART_RATE_EVENT | NO_UNIT | yes | | | Requires Apple Watch to write the data | -| HEART_RATE_VARIABILITY_SDNN | MILLISECONDS | yes | | | Requires Apple Watch to write the data | -| HEADACHE_NOT_PRESENT | MINUTES | yes | | | | -| HEADACHE_MILD | MINUTES | yes | | | | -| HEADACHE_MODERATE | MINUTES | yes | | | | -| HEADACHE_SEVERE | MINUTES | yes | | | | -| HEADACHE_UNSPECIFIED | MINUTES | yes | | | | -| AUDIOGRAM | DECIBEL_HEARING_LEVEL | yes | | | | -| ELECTROCARDIOGRAM | VOLT | yes | | | Requires Apple Watch to write the data | -| NUTRITION | NO_UNIT | yes | yes | yes | | -| INSULIN_DELIVERY | INTERNATIONAL_UNIT | yes | | | | +See the tables below for supported health and workout data types. ## Setup @@ -88,21 +36,36 @@ Step 1: Append the `Info.plist` with the following 2 entries Step 2: Open your Flutter project in Xcode by right clicking on the "ios" folder and selecting "Open in Xcode". Next, enable "HealthKit" by adding a capability inside the "Signing & Capabilities" tab of the Runner target's settings. -### Google Fit (Android option 1) +### Android + +Starting from API level 28 (Android 9.0) accessing some fitness data (e.g. Steps) requires a special permission. To set it add the following line to your `AndroidManifest.xml` file. + +```xml + +``` + +Additionally, for workouts, if the distance of a workout is requested then the location permissions below are needed. + +```xml + + +``` -Follow the guide at +#### Google Fit (Android option 1) -Below is an example of following the guide: +Follow the guide at . Below is an example of following the guide. Change directory to your key-store directory (MacOS): + `cd ~/.android/` Get your keystore SHA1 fingerprint: + `keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android` Example output: -``` +```bash Alias name: androiddebugkey Creation date: Jan 01, 2013 Entry type: PrivateKeyEntry @@ -123,9 +86,9 @@ Follow the instructions at @@ -139,7 +102,7 @@ Health Connect requires the following lines in the `AndroidManifest.xml` file (a In the Health Connect permissions activity there is a link to your privacy policy. You need to grant the Health Connect app access in order to link back to your privacy policy. In the example below, you should either replace `.MainActivity` with an activity that presents the privacy policy or have the Main Activity route the user to the policy. This step may be required to pass Google app review when requesting access to sensitive permissions. -``` +```xml ``` -### Android Permissions - -Starting from API level 28 (Android 9.0) accessing some fitness data (e.g. Steps) requires a special permission. - -To set it add the following line to your `AndroidManifest.xml` file. - -```xml - -``` - -#### Health Connect - If using Health Connect on Android it requires special permissions in the `AndroidManifest.xml` file. The permissions can be found here: Example shown here (can also be found in the example app): @@ -190,15 +141,6 @@ Furthermore, an `intent-filter` needs to be added to the `.MainActivity` activit ``` -#### Workout permissions - -Additionally, for Workouts: If the distance of a workout is requested then the location permissions below are needed. - -```xml - - -``` - There's a `debug`, `main` and `profile` version which are chosen depending on how you start your app. In general, it's sufficient to add permission only to the `main` version. Because this is labeled as a `dangerous` protection level, the permission system will not grant it automatically and it requires the user's action. @@ -214,12 +156,12 @@ await Permission.location.request(); ### Android 14 This plugin uses the new `registerForActivityResult` when requesting permissions from Health Connect. -In order for that to work, the Main app's activity should extend `FlutterFragmentActivity` instead of `FlutterActivity`. +In order for that to work, the Main app's activity should extend `FlutterFragmentActivity` instead of `FlutterActivity`. This adjustment allows casting from `Activity` to `ComponentActivity` for accessing `registerForActivityResult`. In your MainActivity.kt file, update the `MainActivity` class so that it extends `FlutterFragmentActivity` instead of the default `FlutterActivity`: -``` +```kotlin ... import io.flutter.embedding.android.FlutterFragmentActivity ... @@ -243,13 +185,13 @@ android.useAndroidX=true See the example app for detailed examples of how to use the Health API. -The Health plugin is used via the `HealthFactory` class using the different methods for handling permissions and getting and adding data to Apple Health / Google Fit. +The Health plugin is used via the `Health()` singleton using the different methods for handling permissions and getting and adding data to Apple Health, Google Fit, or Google Health Connect. Below is a simplified flow of how to use the plugin. ```dart - // create a HealthFactory for use in the app, choose if HealthConnect should be used or not - HealthFactory health = HealthFactory(useHealthConnectIfAvailable: true); - + // configure the health plugin before use. + Health().configure(useHealthConnectIfAvailable: true); + // define the types to get var types = [ HealthDataType.STEPS, @@ -257,12 +199,12 @@ Below is a simplified flow of how to use the plugin. ]; // requesting access to the data types before reading them - bool requested = await health.requestAuthorization(types); + bool requested = await Health().requestAuthorization(types); var now = DateTime.now(); // fetch health data from the last 24 hours - List healthData = await health.getHealthDataFromTypes( + List healthData = await Health().getHealthDataFromTypes( now.subtract(Duration(days: 1)), now, types); // request permissions to write steps and blood glucose @@ -271,42 +213,64 @@ Below is a simplified flow of how to use the plugin. HealthDataAccess.READ_WRITE, HealthDataAccess.READ_WRITE ]; - await health.requestAuthorization(types, permissions: permissions); + await Health().requestAuthorization(types, permissions: permissions); // write steps and blood glucose - bool success = await health.writeHealthData(10, HealthDataType.STEPS, now, now); - success = await health.writeHealthData(3.1, HealthDataType.BLOOD_GLUCOSE, now, now); + bool success = await Health().writeHealthData(10, HealthDataType.STEPS, now, now); + success = await Health().writeHealthData(3.1, HealthDataType.BLOOD_GLUCOSE, now, now); // get the number of steps for today var midnight = DateTime(now.year, now.month, now.day); - int? steps = await health.getTotalStepsInInterval(midnight, now); + int? steps = await Health().getTotalStepsInInterval(midnight, now); ``` ### Health Data -A `HealthDataPoint` object contains the following data fields: +A [`HealthDataPoint`](https://pub.dev/documentation/health/latest/health/HealthDataPoint-class.html) object contains the following data fields: ```dart -HealthValue value; // NumericHealthValue, AudiogramHealthValue, WorkoutHealthValue, ElectrocardiogramHealthValue +HealthValue value; HealthDataType type; HealthDataUnit unit; DateTime dateFrom; DateTime dateTo; -PlatformType platform; -String uuid, deviceId; +HealthPlatformType sourcePlatform; +String sourceDeviceId; String sourceId; String sourceName; +bool isManualEntry; +WorkoutSummary? workoutSummary; ``` -A `HealthData` object can be serialized to JSON with the `toJson()` method. +where a [`HealthValue`](https://pub.dev/documentation/health/latest/health/HealthValue-class.html) can be any type of `AudiogramHealthValue`, `ElectrocardiogramHealthValue`, `ElectrocardiogramVoltageValue`, `NumericHealthValue`, `NutritionHealthValue`, or `WorkoutHealthValue`. + +A `HealthDataPoint` object can be serialized to and from JSON using the `toJson()` and `fromJson()` methods. JSON serialization is using camel_case notation. Null values are not serialized. For example; + +```json +{ + "value": { + "__type": "NumericHealthValue", + "numeric_value": 141.0 + }, + "type": "STEPS", + "unit": "COUNT", + "date_from": "2024-04-03T10:06:57.736", + "date_to": "2024-04-03T10:12:51.724", + "source_platform": "appleHealth", + "source_device_id": "F74938B9-C011-4DE4-AA5E-CF41B60B96E7", + "source_id": "com.apple.health.81AE7156-EC05-47E3-AC93-2D6F65C717DF", + "source_name": "iPhone12.bardram.net", + "is_manual_entry": false +} +``` ### Fetch health data -See the example here on pub.dev, for a showcasing of how it's done. +See the example app for a showcasing of how it's done. -NB for iOS: The device must be unlocked before Health data can be requested, otherwise an error will be thrown: +**Note** On iOS the device must be unlocked before health data can be requested. Otherwise an error will be thrown: -``` +```bash flutter: Health Plugin Error: flutter: PlatformException(FlutterHealth, Results are null, Optional(Error Domain=com.apple.healthkit Code=6 "Protected health data is inaccessible" UserInfo={NSLocalizedDescription=Protected health data is inaccessible})) ``` @@ -327,17 +291,71 @@ If you have a list of data points, duplicates can be removed with: ```dart List points = ...; -points = Health.removeDuplicates(points); +points = Health().removeDuplicates(points); ``` -## Workouts - -As of 4.0.0 Health supports adding workouts to both iOS and Android. - -### Workout Types +## Data Types -| **Workout Type** | **iOS** | **Android (Google Fit)** | **Android (Health Connect)** | **Comments** | -| -------------------------------- | ------- | ------------------------ | ---------------------------- | ----------------------------------------------------------------- | +The plugin supports the following [`HealthDataType`](https://pub.dev/documentation/health/latest/health/HealthDataType.html). + +| **Data Type** | **Unit** | **Apple Health** | **Google Fit** | **Google Health Connect** | **Comments** | +| --------------------------- | ----------------------- | ------- | ----------------------- |---------------------------| -------------------------------------- | +| ACTIVE_ENERGY_BURNED | CALORIES | yes | yes | yes | | +| BASAL_ENERGY_BURNED | CALORIES | yes | | yes | | +| BLOOD_GLUCOSE | MILLIGRAM_PER_DECILITER | yes | yes | yes | | +| BLOOD_OXYGEN | PERCENTAGE | yes | yes | yes | | +| BLOOD_PRESSURE_DIASTOLIC | MILLIMETER_OF_MERCURY | yes | yes | yes | | +| BLOOD_PRESSURE_SYSTOLIC | MILLIMETER_OF_MERCURY | yes | yes | yes | | +| BODY_FAT_PERCENTAGE | PERCENTAGE | yes | yes | yes | | +| BODY_MASS_INDEX | NO_UNIT | yes | yes | yes | | +| BODY_TEMPERATURE | DEGREE_CELSIUS | yes | yes | yes | | +| BODY_WATER_MASS | KILOGRAMS | | | yes | | +| ELECTRODERMAL_ACTIVITY | SIEMENS | yes | | | | +| HEART_RATE | BEATS_PER_MINUTE | yes | yes | yes | | +| HEIGHT | METERS | yes | yes | yes | | +| RESTING_HEART_RATE | BEATS_PER_MINUTE | yes | | yes | | +| RESPIRATORY_RATE | RESPIRATIONS_PER_MINUTE | yes | | yes | | +| PERIPHERAL_PERFUSION_INDEX | PERCENTAGE | yes | | | | +| STEPS | COUNT | yes | yes | yes | | +| WAIST_CIRCUMFERENCE | METERS | yes | | | | +| WALKING_HEART_RATE | BEATS_PER_MINUTE | yes | | | | +| WEIGHT | KILOGRAMS | yes | yes | yes | | +| DISTANCE_WALKING_RUNNING | METERS | yes | | | | +| FLIGHTS_CLIMBED | COUNT | yes | | yes | | +| MOVE_MINUTES | MINUTES | | yes | | | +| DISTANCE_DELTA | METERS | | yes | yes | | +| MINDFULNESS | MINUTES | yes | | | | +| SLEEP_IN_BED | MINUTES | yes | | | | +| SLEEP_ASLEEP | MINUTES | yes | | yes | | +| SLEEP_AWAKE | MINUTES | yes | | yes | | +| SLEEP_DEEP | MINUTES | yes | | yes | | +| SLEEP_LIGHT | MINUTES | | | yes | | +| SLEEP_REM | MINUTES | yes | | yes | | +| SLEEP_OUT_OF_BED | MINUTES | | | yes | | +| SLEEP_SESSION | MINUTES | | | yes | | +| WATER | LITER | yes | yes | yes | | +| EXERCISE_TIME | MINUTES | yes | | | | +| WORKOUT | NO_UNIT | yes | yes | yes | See table below | +| HIGH_HEART_RATE_EVENT | NO_UNIT | yes | | | Requires Apple Watch to write the data | +| LOW_HEART_RATE_EVENT | NO_UNIT | yes | | | Requires Apple Watch to write the data | +| IRREGULAR_HEART_RATE_EVENT | NO_UNIT | yes | | | Requires Apple Watch to write the data | +| HEART_RATE_VARIABILITY_SDNN | MILLISECONDS | yes | | | Requires Apple Watch to write the data | +| HEADACHE_NOT_PRESENT | MINUTES | yes | | | | +| HEADACHE_MILD | MINUTES | yes | | | | +| HEADACHE_MODERATE | MINUTES | yes | | | | +| HEADACHE_SEVERE | MINUTES | yes | | | | +| HEADACHE_UNSPECIFIED | MINUTES | yes | | | | +| AUDIOGRAM | DECIBEL_HEARING_LEVEL | yes | | | | +| ELECTROCARDIOGRAM | VOLT | yes | | | Requires Apple Watch to write the data | +| NUTRITION | NO_UNIT | yes | yes | yes | | +| INSULIN_DELIVERY | INTERNATIONAL_UNIT | yes | | | | + +## Workout Types + +The plugin supports the following [`HealthWorkoutActivityType`](https://pub.dev/documentation/health/latest/health/HealthWorkoutActivityType.html). + +| **Workout Type** | **Apple Health** | **Google Fit** | **Google Health Connect** | **Comments** | +| -------------------------------- | ------- | ----------------------- | ---------------------------- | ----------------------------------------------------------------- | | ARCHERY | yes | yes | | | | BADMINTON | yes | yes | yes | | | BASEBALL | yes | yes | yes | | diff --git a/packages/health/analysis_options.yaml b/packages/health/analysis_options.yaml new file mode 100644 index 000000000..9565468ca --- /dev/null +++ b/packages/health/analysis_options.yaml @@ -0,0 +1,19 @@ +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: [build/**] + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + cancel_subscriptions: true + constant_identifier_names: false + depend_on_referenced_packages: true + avoid_print: true + use_string_in_part_of_directives: true diff --git a/packages/health/android/build.gradle b/packages/health/android/build.gradle index f976309f3..ee0b26c7f 100644 --- a/packages/health/android/build.gradle +++ b/packages/health/android/build.gradle @@ -58,7 +58,7 @@ dependencies { implementation("com.google.android.gms:play-services-auth:20.2.0") // The new health connect api - implementation("androidx.health.connect:connect-client:1.1.0-alpha06") + implementation("androidx.health.connect:connect-client:1.1.0-alpha07") def fragment_version = "1.6.2" implementation "androidx.fragment:fragment-ktx:$fragment_version" diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt index 424f0afea..323c157c4 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.os.Handler import android.util.Log @@ -20,11 +21,13 @@ import androidx.health.connect.client.records.MealType.MEAL_TYPE_DINNER import androidx.health.connect.client.records.MealType.MEAL_TYPE_LUNCH import androidx.health.connect.client.records.MealType.MEAL_TYPE_SNACK import androidx.health.connect.client.records.MealType.MEAL_TYPE_UNKNOWN +import androidx.health.connect.client.request.AggregateGroupByDurationRequest import androidx.health.connect.client.request.AggregateRequest import androidx.health.connect.client.request.ReadRecordsRequest import androidx.health.connect.client.time.TimeRangeFilter import androidx.health.connect.client.units.* import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.fitness.Fitness import com.google.android.gms.fitness.FitnessActivities import com.google.android.gms.fitness.FitnessOptions @@ -46,12 +49,11 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry.ActivityResultListener import io.flutter.plugin.common.PluginRegistry.Registrar -import kotlinx.coroutines.* import java.time.* import java.time.temporal.ChronoUnit import java.util.* import java.util.concurrent.* - +import kotlinx.coroutines.* const val GOOGLE_FIT_PERMISSIONS_REQUEST_CODE = 1111 const val HEALTH_CONNECT_RESULT_CODE = 16969 @@ -62,2406 +64,3995 @@ const val MMOLL_2_MGDL = 18.0 // 1 mmoll= 18 mgdl const val MIN_SUPPORTED_SDK = Build.VERSION_CODES.O_MR1 class HealthPlugin(private var channel: MethodChannel? = null) : - MethodCallHandler, - ActivityResultListener, - Result, - ActivityAware, - FlutterPlugin { - private var mResult: Result? = null - private var handler: Handler? = null - private var activity: Activity? = null - private var context: Context? = null - private var threadPoolExecutor: ExecutorService? = null - private var useHealthConnectIfAvailable: Boolean = false - private var healthConnectRequestPermissionsLauncher: ActivityResultLauncher>? = null - private lateinit var healthConnectClient: HealthConnectClient - private lateinit var scope: CoroutineScope - - private var BODY_FAT_PERCENTAGE = "BODY_FAT_PERCENTAGE" - private var HEIGHT = "HEIGHT" - private var WEIGHT = "WEIGHT" - private var STEPS = "STEPS" - private var AGGREGATE_STEP_COUNT = "AGGREGATE_STEP_COUNT" - private var ACTIVE_ENERGY_BURNED = "ACTIVE_ENERGY_BURNED" - private var HEART_RATE = "HEART_RATE" - private var BODY_TEMPERATURE = "BODY_TEMPERATURE" - private var BLOOD_PRESSURE_SYSTOLIC = "BLOOD_PRESSURE_SYSTOLIC" - private var BLOOD_PRESSURE_DIASTOLIC = "BLOOD_PRESSURE_DIASTOLIC" - private var BLOOD_OXYGEN = "BLOOD_OXYGEN" - private var BLOOD_GLUCOSE = "BLOOD_GLUCOSE" - private var MOVE_MINUTES = "MOVE_MINUTES" - private var DISTANCE_DELTA = "DISTANCE_DELTA" - private var WATER = "WATER" - private var RESTING_HEART_RATE = "RESTING_HEART_RATE" - private var BASAL_ENERGY_BURNED = "BASAL_ENERGY_BURNED" - private var FLIGHTS_CLIMBED = "FLIGHTS_CLIMBED" - private var RESPIRATORY_RATE = "RESPIRATORY_RATE" - - // TODO support unknown? - private var SLEEP_ASLEEP = "SLEEP_ASLEEP" - private var SLEEP_AWAKE = "SLEEP_AWAKE" - private var SLEEP_IN_BED = "SLEEP_IN_BED" - private var SLEEP_SESSION = "SLEEP_SESSION" - private var SLEEP_LIGHT = "SLEEP_LIGHT" - private var SLEEP_DEEP = "SLEEP_DEEP" - private var SLEEP_REM = "SLEEP_REM" - private var SLEEP_OUT_OF_BED = "SLEEP_OUT_OF_BED" - private var WORKOUT = "WORKOUT" - private var NUTRITION = "NUTRITION" - private var BREAKFAST = "BREAKFAST" - private var LUNCH = "LUNCH" - private var DINNER = "DINNER" - private var SNACK = "SNACK" - private var MEAL_UNKNOWN = "UNKNOWN" - - - val workoutTypeMap = mapOf( - "AEROBICS" to FitnessActivities.AEROBICS, - "AMERICAN_FOOTBALL" to FitnessActivities.FOOTBALL_AMERICAN, - "ARCHERY" to FitnessActivities.ARCHERY, - "AUSTRALIAN_FOOTBALL" to FitnessActivities.FOOTBALL_AUSTRALIAN, - "BADMINTON" to FitnessActivities.BADMINTON, - "BASEBALL" to FitnessActivities.BASEBALL, - "BASKETBALL" to FitnessActivities.BASKETBALL, - "BIATHLON" to FitnessActivities.BIATHLON, - "BIKING" to FitnessActivities.BIKING, - "BIKING_HAND" to FitnessActivities.BIKING_HAND, - "BIKING_MOUNTAIN" to FitnessActivities.BIKING_MOUNTAIN, - "BIKING_ROAD" to FitnessActivities.BIKING_ROAD, - "BIKING_SPINNING" to FitnessActivities.BIKING_SPINNING, - "BIKING_STATIONARY" to FitnessActivities.BIKING_STATIONARY, - "BIKING_UTILITY" to FitnessActivities.BIKING_UTILITY, - "BOXING" to FitnessActivities.BOXING, - "CALISTHENICS" to FitnessActivities.CALISTHENICS, - "CIRCUIT_TRAINING" to FitnessActivities.CIRCUIT_TRAINING, - "CRICKET" to FitnessActivities.CRICKET, - "CROSS_COUNTRY_SKIING" to FitnessActivities.SKIING_CROSS_COUNTRY, - "CROSS_FIT" to FitnessActivities.CROSSFIT, - "CURLING" to FitnessActivities.CURLING, - "DANCING" to FitnessActivities.DANCING, - "DIVING" to FitnessActivities.DIVING, - "DOWNHILL_SKIING" to FitnessActivities.SKIING_DOWNHILL, - "ELEVATOR" to FitnessActivities.ELEVATOR, - "ELLIPTICAL" to FitnessActivities.ELLIPTICAL, - "ERGOMETER" to FitnessActivities.ERGOMETER, - "ESCALATOR" to FitnessActivities.ESCALATOR, - "FENCING" to FitnessActivities.FENCING, - "FRISBEE_DISC" to FitnessActivities.FRISBEE_DISC, - "GARDENING" to FitnessActivities.GARDENING, - "GOLF" to FitnessActivities.GOLF, - "GUIDED_BREATHING" to FitnessActivities.GUIDED_BREATHING, - "GYMNASTICS" to FitnessActivities.GYMNASTICS, - "HANDBALL" to FitnessActivities.HANDBALL, - "HIGH_INTENSITY_INTERVAL_TRAINING" to FitnessActivities.HIGH_INTENSITY_INTERVAL_TRAINING, - "HIKING" to FitnessActivities.HIKING, - "HOCKEY" to FitnessActivities.HOCKEY, - "HORSEBACK_RIDING" to FitnessActivities.HORSEBACK_RIDING, - "HOUSEWORK" to FitnessActivities.HOUSEWORK, - "IN_VEHICLE" to FitnessActivities.IN_VEHICLE, - "ICE_SKATING" to FitnessActivities.ICE_SKATING, - "INTERVAL_TRAINING" to FitnessActivities.INTERVAL_TRAINING, - "JUMP_ROPE" to FitnessActivities.JUMP_ROPE, - "KAYAKING" to FitnessActivities.KAYAKING, - "KETTLEBELL_TRAINING" to FitnessActivities.KETTLEBELL_TRAINING, - "KICK_SCOOTER" to FitnessActivities.KICK_SCOOTER, - "KICKBOXING" to FitnessActivities.KICKBOXING, - "KITE_SURFING" to FitnessActivities.KITESURFING, - "MARTIAL_ARTS" to FitnessActivities.MARTIAL_ARTS, - "MEDITATION" to FitnessActivities.MEDITATION, - "MIXED_MARTIAL_ARTS" to FitnessActivities.MIXED_MARTIAL_ARTS, - "P90X" to FitnessActivities.P90X, - "PARAGLIDING" to FitnessActivities.PARAGLIDING, - "PILATES" to FitnessActivities.PILATES, - "POLO" to FitnessActivities.POLO, - "RACQUETBALL" to FitnessActivities.RACQUETBALL, - "ROCK_CLIMBING" to FitnessActivities.ROCK_CLIMBING, - "ROWING" to FitnessActivities.ROWING, - "ROWING_MACHINE" to FitnessActivities.ROWING_MACHINE, - "RUGBY" to FitnessActivities.RUGBY, - "RUNNING_JOGGING" to FitnessActivities.RUNNING_JOGGING, - "RUNNING_SAND" to FitnessActivities.RUNNING_SAND, - "RUNNING_TREADMILL" to FitnessActivities.RUNNING_TREADMILL, - "RUNNING" to FitnessActivities.RUNNING, - "SAILING" to FitnessActivities.SAILING, - "SCUBA_DIVING" to FitnessActivities.SCUBA_DIVING, - "SKATING_CROSS" to FitnessActivities.SKATING_CROSS, - "SKATING_INDOOR" to FitnessActivities.SKATING_INDOOR, - "SKATING_INLINE" to FitnessActivities.SKATING_INLINE, - "SKATING" to FitnessActivities.SKATING, - "SKIING" to FitnessActivities.SKIING, - "SKIING_BACK_COUNTRY" to FitnessActivities.SKIING_BACK_COUNTRY, - "SKIING_KITE" to FitnessActivities.SKIING_KITE, - "SKIING_ROLLER" to FitnessActivities.SKIING_ROLLER, - "SLEDDING" to FitnessActivities.SLEDDING, - "SNOWBOARDING" to FitnessActivities.SNOWBOARDING, - "SNOWMOBILE" to FitnessActivities.SNOWMOBILE, - "SNOWSHOEING" to FitnessActivities.SNOWSHOEING, - "SOCCER" to FitnessActivities.FOOTBALL_SOCCER, - "SOFTBALL" to FitnessActivities.SOFTBALL, - "SQUASH" to FitnessActivities.SQUASH, - "STAIR_CLIMBING_MACHINE" to FitnessActivities.STAIR_CLIMBING_MACHINE, - "STAIR_CLIMBING" to FitnessActivities.STAIR_CLIMBING, - "STANDUP_PADDLEBOARDING" to FitnessActivities.STANDUP_PADDLEBOARDING, - "STILL" to FitnessActivities.STILL, - "STRENGTH_TRAINING" to FitnessActivities.STRENGTH_TRAINING, - "SURFING" to FitnessActivities.SURFING, - "SWIMMING_OPEN_WATER" to FitnessActivities.SWIMMING_OPEN_WATER, - "SWIMMING_POOL" to FitnessActivities.SWIMMING_POOL, - "SWIMMING" to FitnessActivities.SWIMMING, - "TABLE_TENNIS" to FitnessActivities.TABLE_TENNIS, - "TEAM_SPORTS" to FitnessActivities.TEAM_SPORTS, - "TENNIS" to FitnessActivities.TENNIS, - "TILTING" to FitnessActivities.TILTING, - "VOLLEYBALL_BEACH" to FitnessActivities.VOLLEYBALL_BEACH, - "VOLLEYBALL_INDOOR" to FitnessActivities.VOLLEYBALL_INDOOR, - "VOLLEYBALL" to FitnessActivities.VOLLEYBALL, - "WAKEBOARDING" to FitnessActivities.WAKEBOARDING, - "WALKING_FITNESS" to FitnessActivities.WALKING_FITNESS, - "WALKING_PACED" to FitnessActivities.WALKING_PACED, - "WALKING_NORDIC" to FitnessActivities.WALKING_NORDIC, - "WALKING_STROLLER" to FitnessActivities.WALKING_STROLLER, - "WALKING_TREADMILL" to FitnessActivities.WALKING_TREADMILL, - "WALKING" to FitnessActivities.WALKING, - "WATER_POLO" to FitnessActivities.WATER_POLO, - "WEIGHTLIFTING" to FitnessActivities.WEIGHTLIFTING, - "WHEELCHAIR" to FitnessActivities.WHEELCHAIR, - "WINDSURFING" to FitnessActivities.WINDSURFING, - "YOGA" to FitnessActivities.YOGA, - "ZUMBA" to FitnessActivities.ZUMBA, - "OTHER" to FitnessActivities.OTHER, - ) - - // TODO: Update with new workout types when Health Connect becomes the standard. - val workoutTypeMapHealthConnect = mapOf( - // "AEROBICS" to ExerciseSessionRecord.EXERCISE_TYPE_AEROBICS, - "AMERICAN_FOOTBALL" to ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AMERICAN, - // "ARCHERY" to ExerciseSessionRecord.EXERCISE_TYPE_ARCHERY, - "AUSTRALIAN_FOOTBALL" to ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AUSTRALIAN, - "BADMINTON" to ExerciseSessionRecord.EXERCISE_TYPE_BADMINTON, - "BASEBALL" to ExerciseSessionRecord.EXERCISE_TYPE_BASEBALL, - "BASKETBALL" to ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL, - // "BIATHLON" to ExerciseSessionRecord.EXERCISE_TYPE_BIATHLON, - "BIKING" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING, - // "BIKING_HAND" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_HAND, - //"BIKING_MOUNTAIN" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_MOUNTAIN, - // "BIKING_ROAD" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_ROAD, - // "BIKING_SPINNING" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_SPINNING, - // "BIKING_STATIONARY" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY, - // "BIKING_UTILITY" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_UTILITY, - "BOXING" to ExerciseSessionRecord.EXERCISE_TYPE_BOXING, - "CALISTHENICS" to ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS, - // "CIRCUIT_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_CIRCUIT_TRAINING, - "CRICKET" to ExerciseSessionRecord.EXERCISE_TYPE_CRICKET, - // "CROSS_COUNTRY_SKIING" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_CROSS_COUNTRY, - // "CROSS_FIT" to ExerciseSessionRecord.EXERCISE_TYPE_CROSSFIT, - // "CURLING" to ExerciseSessionRecord.EXERCISE_TYPE_CURLING, - "DANCING" to ExerciseSessionRecord.EXERCISE_TYPE_DANCING, - // "DIVING" to ExerciseSessionRecord.EXERCISE_TYPE_DIVING, - // "DOWNHILL_SKIING" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_DOWNHILL, - // "ELEVATOR" to ExerciseSessionRecord.EXERCISE_TYPE_ELEVATOR, - "ELLIPTICAL" to ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL, - // "ERGOMETER" to ExerciseSessionRecord.EXERCISE_TYPE_ERGOMETER, - // "ESCALATOR" to ExerciseSessionRecord.EXERCISE_TYPE_ESCALATOR, - "FENCING" to ExerciseSessionRecord.EXERCISE_TYPE_FENCING, - "FRISBEE_DISC" to ExerciseSessionRecord.EXERCISE_TYPE_FRISBEE_DISC, - // "GARDENING" to ExerciseSessionRecord.EXERCISE_TYPE_GARDENING, - "GOLF" to ExerciseSessionRecord.EXERCISE_TYPE_GOLF, - "GUIDED_BREATHING" to ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING, - "GYMNASTICS" to ExerciseSessionRecord.EXERCISE_TYPE_GYMNASTICS, - "HANDBALL" to ExerciseSessionRecord.EXERCISE_TYPE_HANDBALL, - "HIGH_INTENSITY_INTERVAL_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING, - "HIKING" to ExerciseSessionRecord.EXERCISE_TYPE_HIKING, - // "HOCKEY" to ExerciseSessionRecord.EXERCISE_TYPE_HOCKEY, - // "HORSEBACK_RIDING" to ExerciseSessionRecord.EXERCISE_TYPE_HORSEBACK_RIDING, - // "HOUSEWORK" to ExerciseSessionRecord.EXERCISE_TYPE_HOUSEWORK, - // "IN_VEHICLE" to ExerciseSessionRecord.EXERCISE_TYPE_IN_VEHICLE, - "ICE_SKATING" to ExerciseSessionRecord.EXERCISE_TYPE_ICE_SKATING, - // "INTERVAL_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_INTERVAL_TRAINING, - // "JUMP_ROPE" to ExerciseSessionRecord.EXERCISE_TYPE_JUMP_ROPE, - // "KAYAKING" to ExerciseSessionRecord.EXERCISE_TYPE_KAYAKING, - // "KETTLEBELL_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_KETTLEBELL_TRAINING, - // "KICK_SCOOTER" to ExerciseSessionRecord.EXERCISE_TYPE_KICK_SCOOTER, - // "KICKBOXING" to ExerciseSessionRecord.EXERCISE_TYPE_KICKBOXING, - // "KITE_SURFING" to ExerciseSessionRecord.EXERCISE_TYPE_KITESURFING, - "MARTIAL_ARTS" to ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS, - // "MEDITATION" to ExerciseSessionRecord.EXERCISE_TYPE_MEDITATION, - // "MIXED_MARTIAL_ARTS" to ExerciseSessionRecord.EXERCISE_TYPE_MIXED_MARTIAL_ARTS, - // "P90X" to ExerciseSessionRecord.EXERCISE_TYPE_P90X, - "PARAGLIDING" to ExerciseSessionRecord.EXERCISE_TYPE_PARAGLIDING, - "PILATES" to ExerciseSessionRecord.EXERCISE_TYPE_PILATES, - // "POLO" to ExerciseSessionRecord.EXERCISE_TYPE_POLO, - "RACQUETBALL" to ExerciseSessionRecord.EXERCISE_TYPE_RACQUETBALL, - "ROCK_CLIMBING" to ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING, - "ROWING" to ExerciseSessionRecord.EXERCISE_TYPE_ROWING, - "ROWING_MACHINE" to ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE, - "RUGBY" to ExerciseSessionRecord.EXERCISE_TYPE_RUGBY, - // "RUNNING_JOGGING" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_JOGGING, - // "RUNNING_SAND" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_SAND, - "RUNNING_TREADMILL" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL, - "RUNNING" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, - "SAILING" to ExerciseSessionRecord.EXERCISE_TYPE_SAILING, - "SCUBA_DIVING" to ExerciseSessionRecord.EXERCISE_TYPE_SCUBA_DIVING, - // "SKATING_CROSS" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING_CROSS, - // "SKATING_INDOOR" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING_INDOOR, - // "SKATING_INLINE" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING_INLINE, - "SKATING" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING, - "SKIING" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING, - // "SKIING_BACK_COUNTRY" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_BACK_COUNTRY, - // "SKIING_KITE" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_KITE, - // "SKIING_ROLLER" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_ROLLER, - // "SLEDDING" to ExerciseSessionRecord.EXERCISE_TYPE_SLEDDING, - "SNOWBOARDING" to ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING, - // "SNOWMOBILE" to ExerciseSessionRecord.EXERCISE_TYPE_SNOWMOBILE, - "SNOWSHOEING" to ExerciseSessionRecord.EXERCISE_TYPE_SNOWSHOEING, - // "SOCCER" to ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_SOCCER, - "SOFTBALL" to ExerciseSessionRecord.EXERCISE_TYPE_SOFTBALL, - "SQUASH" to ExerciseSessionRecord.EXERCISE_TYPE_SQUASH, - "STAIR_CLIMBING_MACHINE" to ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE, - "STAIR_CLIMBING" to ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING, - // "STANDUP_PADDLEBOARDING" to ExerciseSessionRecord.EXERCISE_TYPE_STANDUP_PADDLEBOARDING, - // "STILL" to ExerciseSessionRecord.EXERCISE_TYPE_STILL, - "STRENGTH_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, - "SURFING" to ExerciseSessionRecord.EXERCISE_TYPE_SURFING, - "SWIMMING_OPEN_WATER" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER, - "SWIMMING_POOL" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL, - // "SWIMMING" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING, - "TABLE_TENNIS" to ExerciseSessionRecord.EXERCISE_TYPE_TABLE_TENNIS, - // "TEAM_SPORTS" to ExerciseSessionRecord.EXERCISE_TYPE_TEAM_SPORTS, - "TENNIS" to ExerciseSessionRecord.EXERCISE_TYPE_TENNIS, - // "TILTING" to ExerciseSessionRecord.EXERCISE_TYPE_TILTING, - // "VOLLEYBALL_BEACH" to ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL_BEACH, - // "VOLLEYBALL_INDOOR" to ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL_INDOOR, - "VOLLEYBALL" to ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL, - // "WAKEBOARDING" to ExerciseSessionRecord.EXERCISE_TYPE_WAKEBOARDING, - // "WALKING_FITNESS" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_FITNESS, - // "WALKING_PACED" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_PACED, - // "WALKING_NORDIC" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_NORDIC, - // "WALKING_STROLLER" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_STROLLER, - // "WALKING_TREADMILL" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_TREADMILL, - "WALKING" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING, - "WATER_POLO" to ExerciseSessionRecord.EXERCISE_TYPE_WATER_POLO, - "WEIGHTLIFTING" to ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING, - "WHEELCHAIR" to ExerciseSessionRecord.EXERCISE_TYPE_WHEELCHAIR, - // "WINDSURFING" to ExerciseSessionRecord.EXERCISE_TYPE_WINDSURFING, - "YOGA" to ExerciseSessionRecord.EXERCISE_TYPE_YOGA, - // "ZUMBA" to ExerciseSessionRecord.EXERCISE_TYPE_ZUMBA, - // "OTHER" to ExerciseSessionRecord.EXERCISE_TYPE_OTHER, - ) - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) - channel?.setMethodCallHandler(this) - context = flutterPluginBinding.applicationContext - threadPoolExecutor = Executors.newFixedThreadPool(4) - checkAvailability() - if (healthConnectAvailable) { - healthConnectClient = - HealthConnectClient.getOrCreate(flutterPluginBinding.applicationContext) - } - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel = null - activity = null - threadPoolExecutor!!.shutdown() - threadPoolExecutor = null - } - - // This static function is optional and equivalent to onAttachedToEngine. It supports the old - // pre-Flutter-1.12 Android projects. You are encouraged to continue supporting - // plugin registration via this function while apps migrate to use the new Android APIs - // post-flutter-1.12 via https://flutter.dev/go/android-project-migration. - // - // It is encouraged to share logic between onAttachedToEngine and registerWith to keep - // them functionally equivalent. Only one of onAttachedToEngine or registerWith will be called - // depending on the user's project. onAttachedToEngine or registerWith must both be defined - // in the same class. - companion object { - @Suppress("unused") - @JvmStatic - fun registerWith(registrar: Registrar) { - val channel = MethodChannel(registrar.messenger(), CHANNEL_NAME) - val plugin = HealthPlugin(channel) - registrar.addActivityResultListener(plugin) - channel.setMethodCallHandler(plugin) - } - } - - override fun success(p0: Any?) { - handler?.post { mResult?.success(p0) } - } - - override fun notImplemented() { - handler?.post { mResult?.notImplemented() } - } - - override fun error( - errorCode: String, - errorMessage: String?, - errorDetails: Any?, - ) { - handler?.post { mResult?.error(errorCode, errorMessage, errorDetails) } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - if (requestCode == GOOGLE_FIT_PERMISSIONS_REQUEST_CODE) { - if (resultCode == Activity.RESULT_OK) { - Log.i("FLUTTER_HEALTH", "Access Granted!") - mResult?.success(true) - } else if (resultCode == Activity.RESULT_CANCELED) { - Log.i("FLUTTER_HEALTH", "Access Denied!") - mResult?.success(false) - } - } - return false - } - + MethodCallHandler, ActivityResultListener, Result, ActivityAware, FlutterPlugin { + private var mResult: Result? = null + private var handler: Handler? = null + private var activity: Activity? = null + private var context: Context? = null + private var threadPoolExecutor: ExecutorService? = null + private var useHealthConnectIfAvailable: Boolean = false + private var healthConnectRequestPermissionsLauncher: ActivityResultLauncher>? = + null + private lateinit var healthConnectClient: HealthConnectClient + private lateinit var scope: CoroutineScope + + private var BODY_FAT_PERCENTAGE = "BODY_FAT_PERCENTAGE" + private var HEIGHT = "HEIGHT" + private var WEIGHT = "WEIGHT" + private var STEPS = "STEPS" + private var AGGREGATE_STEP_COUNT = "AGGREGATE_STEP_COUNT" + private var ACTIVE_ENERGY_BURNED = "ACTIVE_ENERGY_BURNED" + private var HEART_RATE = "HEART_RATE" + private var BODY_TEMPERATURE = "BODY_TEMPERATURE" + private var BODY_WATER_MASS = "BODY_WATER_MASS" + private var BLOOD_PRESSURE_SYSTOLIC = "BLOOD_PRESSURE_SYSTOLIC" + private var BLOOD_PRESSURE_DIASTOLIC = "BLOOD_PRESSURE_DIASTOLIC" + private var BLOOD_OXYGEN = "BLOOD_OXYGEN" + private var BLOOD_GLUCOSE = "BLOOD_GLUCOSE" + private var MOVE_MINUTES = "MOVE_MINUTES" + private var DISTANCE_DELTA = "DISTANCE_DELTA" + private var WATER = "WATER" + private var RESTING_HEART_RATE = "RESTING_HEART_RATE" + private var BASAL_ENERGY_BURNED = "BASAL_ENERGY_BURNED" + private var FLIGHTS_CLIMBED = "FLIGHTS_CLIMBED" + private var RESPIRATORY_RATE = "RESPIRATORY_RATE" + + // TODO support unknown? + private var SLEEP_ASLEEP = "SLEEP_ASLEEP" + private var SLEEP_AWAKE = "SLEEP_AWAKE" + private var SLEEP_IN_BED = "SLEEP_IN_BED" + private var SLEEP_SESSION = "SLEEP_SESSION" + private var SLEEP_LIGHT = "SLEEP_LIGHT" + private var SLEEP_DEEP = "SLEEP_DEEP" + private var SLEEP_REM = "SLEEP_REM" + private var SLEEP_OUT_OF_BED = "SLEEP_OUT_OF_BED" + private var WORKOUT = "WORKOUT" + private var NUTRITION = "NUTRITION" + private var BREAKFAST = "BREAKFAST" + private var LUNCH = "LUNCH" + private var DINNER = "DINNER" + private var SNACK = "SNACK" + private var MEAL_UNKNOWN = "UNKNOWN" + + private var TOTAL_CALORIES_BURNED = "TOTAL_CALORIES_BURNED" + + val workoutTypeMap = + mapOf( + "AEROBICS" to FitnessActivities.AEROBICS, + "AMERICAN_FOOTBALL" to FitnessActivities.FOOTBALL_AMERICAN, + "ARCHERY" to FitnessActivities.ARCHERY, + "AUSTRALIAN_FOOTBALL" to + FitnessActivities.FOOTBALL_AUSTRALIAN, + "BADMINTON" to FitnessActivities.BADMINTON, + "BASEBALL" to FitnessActivities.BASEBALL, + "BASKETBALL" to FitnessActivities.BASKETBALL, + "BIATHLON" to FitnessActivities.BIATHLON, + "BIKING" to FitnessActivities.BIKING, + "BIKING_HAND" to FitnessActivities.BIKING_HAND, + "BIKING_MOUNTAIN" to FitnessActivities.BIKING_MOUNTAIN, + "BIKING_ROAD" to FitnessActivities.BIKING_ROAD, + "BIKING_SPINNING" to FitnessActivities.BIKING_SPINNING, + "BIKING_STATIONARY" to FitnessActivities.BIKING_STATIONARY, + "BIKING_UTILITY" to FitnessActivities.BIKING_UTILITY, + "BOXING" to FitnessActivities.BOXING, + "CALISTHENICS" to FitnessActivities.CALISTHENICS, + "CIRCUIT_TRAINING" to FitnessActivities.CIRCUIT_TRAINING, + "CRICKET" to FitnessActivities.CRICKET, + "CROSS_COUNTRY_SKIING" to + FitnessActivities.SKIING_CROSS_COUNTRY, + "CROSS_FIT" to FitnessActivities.CROSSFIT, + "CURLING" to FitnessActivities.CURLING, + "DANCING" to FitnessActivities.DANCING, + "DIVING" to FitnessActivities.DIVING, + "DOWNHILL_SKIING" to FitnessActivities.SKIING_DOWNHILL, + "ELEVATOR" to FitnessActivities.ELEVATOR, + "ELLIPTICAL" to FitnessActivities.ELLIPTICAL, + "ERGOMETER" to FitnessActivities.ERGOMETER, + "ESCALATOR" to FitnessActivities.ESCALATOR, + "FENCING" to FitnessActivities.FENCING, + "FRISBEE_DISC" to FitnessActivities.FRISBEE_DISC, + "GARDENING" to FitnessActivities.GARDENING, + "GOLF" to FitnessActivities.GOLF, + "GUIDED_BREATHING" to FitnessActivities.GUIDED_BREATHING, + "GYMNASTICS" to FitnessActivities.GYMNASTICS, + "HANDBALL" to FitnessActivities.HANDBALL, + "HIGH_INTENSITY_INTERVAL_TRAINING" to + FitnessActivities + .HIGH_INTENSITY_INTERVAL_TRAINING, + "HIKING" to FitnessActivities.HIKING, + "HOCKEY" to FitnessActivities.HOCKEY, + "HORSEBACK_RIDING" to FitnessActivities.HORSEBACK_RIDING, + "HOUSEWORK" to FitnessActivities.HOUSEWORK, + "IN_VEHICLE" to FitnessActivities.IN_VEHICLE, + "ICE_SKATING" to FitnessActivities.ICE_SKATING, + "INTERVAL_TRAINING" to FitnessActivities.INTERVAL_TRAINING, + "JUMP_ROPE" to FitnessActivities.JUMP_ROPE, + "KAYAKING" to FitnessActivities.KAYAKING, + "KETTLEBELL_TRAINING" to + FitnessActivities.KETTLEBELL_TRAINING, + "KICK_SCOOTER" to FitnessActivities.KICK_SCOOTER, + "KICKBOXING" to FitnessActivities.KICKBOXING, + "KITE_SURFING" to FitnessActivities.KITESURFING, + "MARTIAL_ARTS" to FitnessActivities.MARTIAL_ARTS, + "MEDITATION" to FitnessActivities.MEDITATION, + "MIXED_MARTIAL_ARTS" to + FitnessActivities.MIXED_MARTIAL_ARTS, + "P90X" to FitnessActivities.P90X, + "PARAGLIDING" to FitnessActivities.PARAGLIDING, + "PILATES" to FitnessActivities.PILATES, + "POLO" to FitnessActivities.POLO, + "RACQUETBALL" to FitnessActivities.RACQUETBALL, + "ROCK_CLIMBING" to FitnessActivities.ROCK_CLIMBING, + "ROWING" to FitnessActivities.ROWING, + "ROWING_MACHINE" to FitnessActivities.ROWING_MACHINE, + "RUGBY" to FitnessActivities.RUGBY, + "RUNNING_JOGGING" to FitnessActivities.RUNNING_JOGGING, + "RUNNING_SAND" to FitnessActivities.RUNNING_SAND, + "RUNNING_TREADMILL" to FitnessActivities.RUNNING_TREADMILL, + "RUNNING" to FitnessActivities.RUNNING, + "SAILING" to FitnessActivities.SAILING, + "SCUBA_DIVING" to FitnessActivities.SCUBA_DIVING, + "SKATING_CROSS" to FitnessActivities.SKATING_CROSS, + "SKATING_INDOOR" to FitnessActivities.SKATING_INDOOR, + "SKATING_INLINE" to FitnessActivities.SKATING_INLINE, + "SKATING" to FitnessActivities.SKATING, + "SKIING" to FitnessActivities.SKIING, + "SKIING_BACK_COUNTRY" to + FitnessActivities.SKIING_BACK_COUNTRY, + "SKIING_KITE" to FitnessActivities.SKIING_KITE, + "SKIING_ROLLER" to FitnessActivities.SKIING_ROLLER, + "SLEDDING" to FitnessActivities.SLEDDING, + "SNOWBOARDING" to FitnessActivities.SNOWBOARDING, + "SNOWMOBILE" to FitnessActivities.SNOWMOBILE, + "SNOWSHOEING" to FitnessActivities.SNOWSHOEING, + "SOCCER" to FitnessActivities.FOOTBALL_SOCCER, + "SOFTBALL" to FitnessActivities.SOFTBALL, + "SQUASH" to FitnessActivities.SQUASH, + "STAIR_CLIMBING_MACHINE" to + FitnessActivities.STAIR_CLIMBING_MACHINE, + "STAIR_CLIMBING" to FitnessActivities.STAIR_CLIMBING, + "STANDUP_PADDLEBOARDING" to + FitnessActivities.STANDUP_PADDLEBOARDING, + "STILL" to FitnessActivities.STILL, + "STRENGTH_TRAINING" to FitnessActivities.STRENGTH_TRAINING, + "SURFING" to FitnessActivities.SURFING, + "SWIMMING_OPEN_WATER" to + FitnessActivities.SWIMMING_OPEN_WATER, + "SWIMMING_POOL" to FitnessActivities.SWIMMING_POOL, + "SWIMMING" to FitnessActivities.SWIMMING, + "TABLE_TENNIS" to FitnessActivities.TABLE_TENNIS, + "TEAM_SPORTS" to FitnessActivities.TEAM_SPORTS, + "TENNIS" to FitnessActivities.TENNIS, + "TILTING" to FitnessActivities.TILTING, + "VOLLEYBALL_BEACH" to FitnessActivities.VOLLEYBALL_BEACH, + "VOLLEYBALL_INDOOR" to FitnessActivities.VOLLEYBALL_INDOOR, + "VOLLEYBALL" to FitnessActivities.VOLLEYBALL, + "WAKEBOARDING" to FitnessActivities.WAKEBOARDING, + "WALKING_FITNESS" to FitnessActivities.WALKING_FITNESS, + "WALKING_PACED" to FitnessActivities.WALKING_PACED, + "WALKING_NORDIC" to FitnessActivities.WALKING_NORDIC, + "WALKING_STROLLER" to FitnessActivities.WALKING_STROLLER, + "WALKING_TREADMILL" to FitnessActivities.WALKING_TREADMILL, + "WALKING" to FitnessActivities.WALKING, + "WATER_POLO" to FitnessActivities.WATER_POLO, + "WEIGHTLIFTING" to FitnessActivities.WEIGHTLIFTING, + "WHEELCHAIR" to FitnessActivities.WHEELCHAIR, + "WINDSURFING" to FitnessActivities.WINDSURFING, + "YOGA" to FitnessActivities.YOGA, + "ZUMBA" to FitnessActivities.ZUMBA, + "OTHER" to FitnessActivities.OTHER, + ) - private fun onHealthConnectPermissionCallback(permissionGranted: Set) - { - if(permissionGranted.isEmpty()) { - mResult?.success(false); - Log.i("FLUTTER_HEALTH", "Access Denied (to Health Connect)!") + // TODO: Update with new workout types when Health Connect becomes the standard. + val workoutTypeMapHealthConnect = + mapOf( + // "AEROBICS" to + // ExerciseSessionRecord.EXERCISE_TYPE_AEROBICS, + "AMERICAN_FOOTBALL" to + ExerciseSessionRecord + .EXERCISE_TYPE_FOOTBALL_AMERICAN, + // "ARCHERY" to ExerciseSessionRecord.EXERCISE_TYPE_ARCHERY, + "AUSTRALIAN_FOOTBALL" to + ExerciseSessionRecord + .EXERCISE_TYPE_FOOTBALL_AUSTRALIAN, + "BADMINTON" to + ExerciseSessionRecord + .EXERCISE_TYPE_BADMINTON, + "BASEBALL" to ExerciseSessionRecord.EXERCISE_TYPE_BASEBALL, + "BASKETBALL" to + ExerciseSessionRecord + .EXERCISE_TYPE_BASKETBALL, + // "BIATHLON" to + // ExerciseSessionRecord.EXERCISE_TYPE_BIATHLON, + "BIKING" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING, + // "BIKING_HAND" to + // ExerciseSessionRecord.EXERCISE_TYPE_BIKING_HAND, + // "BIKING_MOUNTAIN" to + // ExerciseSessionRecord.EXERCISE_TYPE_BIKING_MOUNTAIN, + // "BIKING_ROAD" to + // ExerciseSessionRecord.EXERCISE_TYPE_BIKING_ROAD, + // "BIKING_SPINNING" to + // ExerciseSessionRecord.EXERCISE_TYPE_BIKING_SPINNING, + // "BIKING_STATIONARY" to + // ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY, + // "BIKING_UTILITY" to + // ExerciseSessionRecord.EXERCISE_TYPE_BIKING_UTILITY, + "BOXING" to ExerciseSessionRecord.EXERCISE_TYPE_BOXING, + "CALISTHENICS" to + ExerciseSessionRecord + .EXERCISE_TYPE_CALISTHENICS, + // "CIRCUIT_TRAINING" to + // ExerciseSessionRecord.EXERCISE_TYPE_CIRCUIT_TRAINING, + "CRICKET" to ExerciseSessionRecord.EXERCISE_TYPE_CRICKET, + // "CROSS_COUNTRY_SKIING" to + // ExerciseSessionRecord.EXERCISE_TYPE_SKIING_CROSS_COUNTRY, + // "CROSS_FIT" to + // ExerciseSessionRecord.EXERCISE_TYPE_CROSSFIT, + // "CURLING" to ExerciseSessionRecord.EXERCISE_TYPE_CURLING, + "DANCING" to ExerciseSessionRecord.EXERCISE_TYPE_DANCING, + // "DIVING" to ExerciseSessionRecord.EXERCISE_TYPE_DIVING, + // "DOWNHILL_SKIING" to + // ExerciseSessionRecord.EXERCISE_TYPE_SKIING_DOWNHILL, + // "ELEVATOR" to + // ExerciseSessionRecord.EXERCISE_TYPE_ELEVATOR, + "ELLIPTICAL" to + ExerciseSessionRecord + .EXERCISE_TYPE_ELLIPTICAL, + // "ERGOMETER" to + // ExerciseSessionRecord.EXERCISE_TYPE_ERGOMETER, + // "ESCALATOR" to + // ExerciseSessionRecord.EXERCISE_TYPE_ESCALATOR, + "FENCING" to ExerciseSessionRecord.EXERCISE_TYPE_FENCING, + "FRISBEE_DISC" to + ExerciseSessionRecord + .EXERCISE_TYPE_FRISBEE_DISC, + // "GARDENING" to + // ExerciseSessionRecord.EXERCISE_TYPE_GARDENING, + "GOLF" to ExerciseSessionRecord.EXERCISE_TYPE_GOLF, + "GUIDED_BREATHING" to + ExerciseSessionRecord + .EXERCISE_TYPE_GUIDED_BREATHING, + "GYMNASTICS" to + ExerciseSessionRecord + .EXERCISE_TYPE_GYMNASTICS, + "HANDBALL" to ExerciseSessionRecord.EXERCISE_TYPE_HANDBALL, + "HIGH_INTENSITY_INTERVAL_TRAINING" to + ExerciseSessionRecord + .EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING, + "HIKING" to ExerciseSessionRecord.EXERCISE_TYPE_HIKING, + // "HOCKEY" to ExerciseSessionRecord.EXERCISE_TYPE_HOCKEY, + // "HORSEBACK_RIDING" to + // ExerciseSessionRecord.EXERCISE_TYPE_HORSEBACK_RIDING, + // "HOUSEWORK" to + // ExerciseSessionRecord.EXERCISE_TYPE_HOUSEWORK, + // "IN_VEHICLE" to + // ExerciseSessionRecord.EXERCISE_TYPE_IN_VEHICLE, + "ICE_SKATING" to + ExerciseSessionRecord + .EXERCISE_TYPE_ICE_SKATING, + // "INTERVAL_TRAINING" to + // ExerciseSessionRecord.EXERCISE_TYPE_INTERVAL_TRAINING, + // "JUMP_ROPE" to + // ExerciseSessionRecord.EXERCISE_TYPE_JUMP_ROPE, + // "KAYAKING" to + // ExerciseSessionRecord.EXERCISE_TYPE_KAYAKING, + // "KETTLEBELL_TRAINING" to + // ExerciseSessionRecord.EXERCISE_TYPE_KETTLEBELL_TRAINING, + // "KICK_SCOOTER" to + // ExerciseSessionRecord.EXERCISE_TYPE_KICK_SCOOTER, + // "KICKBOXING" to + // ExerciseSessionRecord.EXERCISE_TYPE_KICKBOXING, + // "KITE_SURFING" to + // ExerciseSessionRecord.EXERCISE_TYPE_KITESURFING, + "MARTIAL_ARTS" to + ExerciseSessionRecord + .EXERCISE_TYPE_MARTIAL_ARTS, + // "MEDITATION" to + // ExerciseSessionRecord.EXERCISE_TYPE_MEDITATION, + // "MIXED_MARTIAL_ARTS" to + // ExerciseSessionRecord.EXERCISE_TYPE_MIXED_MARTIAL_ARTS, + // "P90X" to ExerciseSessionRecord.EXERCISE_TYPE_P90X, + "PARAGLIDING" to + ExerciseSessionRecord + .EXERCISE_TYPE_PARAGLIDING, + "PILATES" to ExerciseSessionRecord.EXERCISE_TYPE_PILATES, + // "POLO" to ExerciseSessionRecord.EXERCISE_TYPE_POLO, + "RACQUETBALL" to + ExerciseSessionRecord + .EXERCISE_TYPE_RACQUETBALL, + "ROCK_CLIMBING" to + ExerciseSessionRecord + .EXERCISE_TYPE_ROCK_CLIMBING, + "ROWING" to ExerciseSessionRecord.EXERCISE_TYPE_ROWING, + "ROWING_MACHINE" to + ExerciseSessionRecord + .EXERCISE_TYPE_ROWING_MACHINE, + "RUGBY" to ExerciseSessionRecord.EXERCISE_TYPE_RUGBY, + // "RUNNING_JOGGING" to + // ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_JOGGING, + // "RUNNING_SAND" to + // ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_SAND, + "RUNNING_TREADMILL" to + ExerciseSessionRecord + .EXERCISE_TYPE_RUNNING_TREADMILL, + "RUNNING" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, + "SAILING" to ExerciseSessionRecord.EXERCISE_TYPE_SAILING, + "SCUBA_DIVING" to + ExerciseSessionRecord + .EXERCISE_TYPE_SCUBA_DIVING, + // "SKATING_CROSS" to + // ExerciseSessionRecord.EXERCISE_TYPE_SKATING_CROSS, + // "SKATING_INDOOR" to + // ExerciseSessionRecord.EXERCISE_TYPE_SKATING_INDOOR, + // "SKATING_INLINE" to + // ExerciseSessionRecord.EXERCISE_TYPE_SKATING_INLINE, + "SKATING" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING, + "SKIING" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING, + // "SKIING_BACK_COUNTRY" to + // ExerciseSessionRecord.EXERCISE_TYPE_SKIING_BACK_COUNTRY, + // "SKIING_KITE" to + // ExerciseSessionRecord.EXERCISE_TYPE_SKIING_KITE, + // "SKIING_ROLLER" to + // ExerciseSessionRecord.EXERCISE_TYPE_SKIING_ROLLER, + // "SLEDDING" to + // ExerciseSessionRecord.EXERCISE_TYPE_SLEDDING, + "SNOWBOARDING" to + ExerciseSessionRecord + .EXERCISE_TYPE_SNOWBOARDING, + // "SNOWMOBILE" to + // ExerciseSessionRecord.EXERCISE_TYPE_SNOWMOBILE, + "SNOWSHOEING" to + ExerciseSessionRecord + .EXERCISE_TYPE_SNOWSHOEING, + // "SOCCER" to + // ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_SOCCER, + "SOFTBALL" to ExerciseSessionRecord.EXERCISE_TYPE_SOFTBALL, + "SQUASH" to ExerciseSessionRecord.EXERCISE_TYPE_SQUASH, + "STAIR_CLIMBING_MACHINE" to + ExerciseSessionRecord + .EXERCISE_TYPE_STAIR_CLIMBING_MACHINE, + "STAIR_CLIMBING" to + ExerciseSessionRecord + .EXERCISE_TYPE_STAIR_CLIMBING, + // "STANDUP_PADDLEBOARDING" to + // ExerciseSessionRecord.EXERCISE_TYPE_STANDUP_PADDLEBOARDING, + // "STILL" to ExerciseSessionRecord.EXERCISE_TYPE_STILL, + "STRENGTH_TRAINING" to + ExerciseSessionRecord + .EXERCISE_TYPE_STRENGTH_TRAINING, + "SURFING" to ExerciseSessionRecord.EXERCISE_TYPE_SURFING, + "SWIMMING_OPEN_WATER" to + ExerciseSessionRecord + .EXERCISE_TYPE_SWIMMING_OPEN_WATER, + "SWIMMING_POOL" to + ExerciseSessionRecord + .EXERCISE_TYPE_SWIMMING_POOL, + // "SWIMMING" to + // ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING, + "TABLE_TENNIS" to + ExerciseSessionRecord + .EXERCISE_TYPE_TABLE_TENNIS, + // "TEAM_SPORTS" to + // ExerciseSessionRecord.EXERCISE_TYPE_TEAM_SPORTS, + "TENNIS" to ExerciseSessionRecord.EXERCISE_TYPE_TENNIS, + // "TILTING" to ExerciseSessionRecord.EXERCISE_TYPE_TILTING, + // "VOLLEYBALL_BEACH" to + // ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL_BEACH, + // "VOLLEYBALL_INDOOR" to + // ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL_INDOOR, + "VOLLEYBALL" to + ExerciseSessionRecord + .EXERCISE_TYPE_VOLLEYBALL, + // "WAKEBOARDING" to + // ExerciseSessionRecord.EXERCISE_TYPE_WAKEBOARDING, + // "WALKING_FITNESS" to + // ExerciseSessionRecord.EXERCISE_TYPE_WALKING_FITNESS, + // "WALKING_PACED" to + // ExerciseSessionRecord.EXERCISE_TYPE_WALKING_PACED, + // "WALKING_NORDIC" to + // ExerciseSessionRecord.EXERCISE_TYPE_WALKING_NORDIC, + // "WALKING_STROLLER" to + // ExerciseSessionRecord.EXERCISE_TYPE_WALKING_STROLLER, + // "WALKING_TREADMILL" to + // ExerciseSessionRecord.EXERCISE_TYPE_WALKING_TREADMILL, + "WALKING" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING, + "WATER_POLO" to + ExerciseSessionRecord + .EXERCISE_TYPE_WATER_POLO, + "WEIGHTLIFTING" to + ExerciseSessionRecord + .EXERCISE_TYPE_WEIGHTLIFTING, + "WHEELCHAIR" to + ExerciseSessionRecord + .EXERCISE_TYPE_WHEELCHAIR, + // "WINDSURFING" to + // ExerciseSessionRecord.EXERCISE_TYPE_WINDSURFING, + "YOGA" to ExerciseSessionRecord.EXERCISE_TYPE_YOGA, + // "ZUMBA" to ExerciseSessionRecord.EXERCISE_TYPE_ZUMBA, + // "OTHER" to ExerciseSessionRecord.EXERCISE_TYPE_OTHER, + ) - }else { - mResult?.success(true); - Log.i("FLUTTER_HEALTH", "Access Granted (to Health Connect)!") + override fun onAttachedToEngine( + @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding + ) { + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) + channel?.setMethodCallHandler(this) + context = flutterPluginBinding.applicationContext + threadPoolExecutor = Executors.newFixedThreadPool(4) + checkAvailability() + if (healthConnectAvailable) { + healthConnectClient = + HealthConnectClient.getOrCreate( + flutterPluginBinding.applicationContext + ) + } } - } - - private fun keyToHealthDataType(type: String): DataType { - return when (type) { - BODY_FAT_PERCENTAGE -> DataType.TYPE_BODY_FAT_PERCENTAGE - HEIGHT -> DataType.TYPE_HEIGHT - WEIGHT -> DataType.TYPE_WEIGHT - STEPS -> DataType.TYPE_STEP_COUNT_DELTA - AGGREGATE_STEP_COUNT -> DataType.AGGREGATE_STEP_COUNT_DELTA - ACTIVE_ENERGY_BURNED -> DataType.TYPE_CALORIES_EXPENDED - HEART_RATE -> DataType.TYPE_HEART_RATE_BPM - BODY_TEMPERATURE -> HealthDataTypes.TYPE_BODY_TEMPERATURE - BLOOD_PRESSURE_SYSTOLIC -> HealthDataTypes.TYPE_BLOOD_PRESSURE - BLOOD_PRESSURE_DIASTOLIC -> HealthDataTypes.TYPE_BLOOD_PRESSURE - BLOOD_OXYGEN -> HealthDataTypes.TYPE_OXYGEN_SATURATION - BLOOD_GLUCOSE -> HealthDataTypes.TYPE_BLOOD_GLUCOSE - MOVE_MINUTES -> DataType.TYPE_MOVE_MINUTES - DISTANCE_DELTA -> DataType.TYPE_DISTANCE_DELTA - WATER -> DataType.TYPE_HYDRATION - SLEEP_ASLEEP -> DataType.TYPE_SLEEP_SEGMENT - SLEEP_AWAKE -> DataType.TYPE_SLEEP_SEGMENT - SLEEP_IN_BED -> DataType.TYPE_SLEEP_SEGMENT - SLEEP_LIGHT -> DataType.TYPE_SLEEP_SEGMENT - SLEEP_REM -> DataType.TYPE_SLEEP_SEGMENT - SLEEP_DEEP -> DataType.TYPE_SLEEP_SEGMENT - WORKOUT -> DataType.TYPE_ACTIVITY_SEGMENT - NUTRITION -> DataType.TYPE_NUTRITION - else -> throw IllegalArgumentException("Unsupported dataType: $type") - } - } - - private fun getField(type: String): Field { - return when (type) { - BODY_FAT_PERCENTAGE -> Field.FIELD_PERCENTAGE - HEIGHT -> Field.FIELD_HEIGHT - WEIGHT -> Field.FIELD_WEIGHT - STEPS -> Field.FIELD_STEPS - ACTIVE_ENERGY_BURNED -> Field.FIELD_CALORIES - HEART_RATE -> Field.FIELD_BPM - BODY_TEMPERATURE -> HealthFields.FIELD_BODY_TEMPERATURE - BLOOD_PRESSURE_SYSTOLIC -> HealthFields.FIELD_BLOOD_PRESSURE_SYSTOLIC - BLOOD_PRESSURE_DIASTOLIC -> HealthFields.FIELD_BLOOD_PRESSURE_DIASTOLIC - BLOOD_OXYGEN -> HealthFields.FIELD_OXYGEN_SATURATION - BLOOD_GLUCOSE -> HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL - MOVE_MINUTES -> Field.FIELD_DURATION - DISTANCE_DELTA -> Field.FIELD_DISTANCE - WATER -> Field.FIELD_VOLUME - SLEEP_ASLEEP -> Field.FIELD_SLEEP_SEGMENT_TYPE - SLEEP_AWAKE -> Field.FIELD_SLEEP_SEGMENT_TYPE - SLEEP_IN_BED -> Field.FIELD_SLEEP_SEGMENT_TYPE - SLEEP_LIGHT -> Field.FIELD_SLEEP_SEGMENT_TYPE - SLEEP_REM -> Field.FIELD_SLEEP_SEGMENT_TYPE - SLEEP_DEEP -> Field.FIELD_SLEEP_SEGMENT_TYPE - WORKOUT -> Field.FIELD_ACTIVITY - NUTRITION -> Field.FIELD_NUTRIENTS - else -> throw IllegalArgumentException("Unsupported dataType: $type") - } - } - - private fun isIntField(dataSource: DataSource, unit: Field): Boolean { - val dataPoint = DataPoint.builder(dataSource).build() - val value = dataPoint.getValue(unit) - return value.format == Field.FORMAT_INT32 - } - - // / Extracts the (numeric) value from a Health Data Point - private fun getHealthDataValue(dataPoint: DataPoint, field: Field): Any { - val value = dataPoint.getValue(field) - // Conversion is needed because glucose is stored as mmoll in Google Fit; - // while mgdl is used for glucose in this plugin. - val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL - return when (value.format) { - Field.FORMAT_FLOAT -> if (!isGlucose) value.asFloat() else value.asFloat() * MMOLL_2_MGDL - Field.FORMAT_INT32 -> value.asInt() - Field.FORMAT_STRING -> value.asString() - else -> Log.e("Unsupported format:", value.format.toString()) - } - } - - /** - * Delete records of the given type in the time range - */ - private fun delete(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - deleteHCData(call, result) - return - } - if (context == null) { - result.success(false) - return + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel = null + activity = null + threadPoolExecutor!!.shutdown() + threadPoolExecutor = null + } + + // This static function is optional and equivalent to onAttachedToEngine. It supports the + // old + // pre-Flutter-1.12 Android projects. You are encouraged to continue supporting + // plugin registration via this function while apps migrate to use the new Android APIs + // post-flutter-1.12 via https://flutter.dev/go/android-project-migration. + // + // It is encouraged to share logic between onAttachedToEngine and registerWith to keep + // them functionally equivalent. Only one of onAttachedToEngine or registerWith will be + // called + // depending on the user's project. onAttachedToEngine or registerWith must both be defined + // in the same class. + companion object { + @Suppress("unused") + @JvmStatic + fun registerWith(registrar: Registrar) { + val channel = MethodChannel(registrar.messenger(), CHANNEL_NAME) + val plugin = HealthPlugin(channel) + registrar.addActivityResultListener(plugin) + channel.setMethodCallHandler(plugin) + } } - val type = call.argument("dataTypeKey")!! - val startTime = call.argument("startTime")!! - val endTime = call.argument("endTime")!! - - // Look up data type and unit for the type key - val dataType = keyToHealthDataType(type) - val field = getField(type) - - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - - val dataSource = DataDeleteRequest.Builder() - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .addDataType(dataType) - .deleteAllSessions() - .build() - - val fitnessOptions = typesBuilder.build() - - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .deleteData(dataSource) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Dataset deleted successfully!") - result.success(true) - } - .addOnFailureListener(errHandler(result, "There was an error deleting the dataset")) - } catch (e3: Exception) { - result.success(false) + override fun success(p0: Any?) { + handler?.post { mResult?.success(p0) } } - } - - /** - * Save a Blood Pressure measurement with systolic and diastolic values - */ - private fun writeBloodPressure(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - writeBloodPressureHC(call, result) - return + + override fun notImplemented() { + handler?.post { mResult?.notImplemented() } } - if (context == null) { - result.success(false) - return + + override fun error( + errorCode: String, + errorMessage: String?, + errorDetails: Any?, + ) { + handler?.post { mResult?.error(errorCode, errorMessage, errorDetails) } } - val dataType = HealthDataTypes.TYPE_BLOOD_PRESSURE - val systolic = call.argument("systolic")!! - val diastolic = call.argument("diastolic")!! - val startTime = call.argument("startTime")!! - val endTime = call.argument("endTime")!! - - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - - val dataSource = DataSource.Builder() - .setDataType(dataType) - .setType(DataSource.TYPE_RAW) - .setDevice(Device.getLocalDevice(context!!.applicationContext)) - .setAppPackageName(context!!.applicationContext) - .build() - - val builder = DataPoint.builder(dataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .setField(HealthFields.FIELD_BLOOD_PRESSURE_SYSTOLIC, systolic) - .setField(HealthFields.FIELD_BLOOD_PRESSURE_DIASTOLIC, diastolic) - .build() - - val dataPoint = builder - val dataSet = DataSet.builder(dataSource) - .add(dataPoint) - .build() - - val fitnessOptions = typesBuilder.build() - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .insertData(dataSet) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Blood Pressure added successfully!") - result.success(true) - } - .addOnFailureListener( - errHandler( - result, - "There was an error adding the blood pressure data!", - ), - ) - } catch (e3: Exception) { - result.success(false) + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode == GOOGLE_FIT_PERMISSIONS_REQUEST_CODE) { + if (resultCode == Activity.RESULT_OK) { + Log.i("FLUTTER_HEALTH", "Access Granted!") + mResult?.success(true) + } else if (resultCode == Activity.RESULT_CANCELED) { + Log.i("FLUTTER_HEALTH", "Access Denied!") + mResult?.success(false) + } + } + return false } - } - - private fun writeMealHC(call: MethodCall, result: Result) { - val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) - val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) - val calories = call.argument("caloriesConsumed") - val carbs = call.argument("carbohydrates") as Double? - val protein = call.argument("protein") as Double? - val fat = call.argument("fatTotal") as Double? - val name = call.argument("name") - val mealType = call.argument("mealType")!! - - scope.launch { - try { - val list = mutableListOf() - list.add( - NutritionRecord( - name = name, - energy = calories?.kilocalories, - totalCarbohydrate = carbs?.grams, - protein = protein?.grams, - totalFat = fat?.grams, - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - mealType = MapMealTypeToTypeHC[mealType] ?: MEAL_TYPE_UNKNOWN, - ), - ) - healthConnectClient.insertRecords( - list, - ) - result.success(true) - Log.i("FLUTTER_HEALTH::SUCCESS", "[Health Connect] Meal was successfully added!") - } catch (e: Exception) { - Log.w( - "FLUTTER_HEALTH::ERROR", - "[Health Connect] There was an error adding the meal", - ) - Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") - Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) - result.success(false) - } + private fun onHealthConnectPermissionCallback(permissionGranted: Set) { + if (permissionGranted.isEmpty()) { + mResult?.success(false) + Log.i("FLUTTER_HEALTH", "Access Denied (to Health Connect)!") + } else { + mResult?.success(true) + Log.i("FLUTTER_HEALTH", "Access Granted (to Health Connect)!") + } + } + private fun keyToHealthDataType(type: String): DataType { + return when (type) { + BODY_FAT_PERCENTAGE -> DataType.TYPE_BODY_FAT_PERCENTAGE + HEIGHT -> DataType.TYPE_HEIGHT + WEIGHT -> DataType.TYPE_WEIGHT + STEPS -> DataType.TYPE_STEP_COUNT_DELTA + AGGREGATE_STEP_COUNT -> DataType.AGGREGATE_STEP_COUNT_DELTA + ACTIVE_ENERGY_BURNED -> DataType.TYPE_CALORIES_EXPENDED + HEART_RATE -> DataType.TYPE_HEART_RATE_BPM + BODY_TEMPERATURE -> HealthDataTypes.TYPE_BODY_TEMPERATURE + BLOOD_PRESSURE_SYSTOLIC -> HealthDataTypes.TYPE_BLOOD_PRESSURE + BLOOD_PRESSURE_DIASTOLIC -> HealthDataTypes.TYPE_BLOOD_PRESSURE + BLOOD_OXYGEN -> HealthDataTypes.TYPE_OXYGEN_SATURATION + BLOOD_GLUCOSE -> HealthDataTypes.TYPE_BLOOD_GLUCOSE + MOVE_MINUTES -> DataType.TYPE_MOVE_MINUTES + DISTANCE_DELTA -> DataType.TYPE_DISTANCE_DELTA + WATER -> DataType.TYPE_HYDRATION + SLEEP_ASLEEP -> DataType.TYPE_SLEEP_SEGMENT + SLEEP_AWAKE -> DataType.TYPE_SLEEP_SEGMENT + SLEEP_IN_BED -> DataType.TYPE_SLEEP_SEGMENT + SLEEP_LIGHT -> DataType.TYPE_SLEEP_SEGMENT + SLEEP_REM -> DataType.TYPE_SLEEP_SEGMENT + SLEEP_DEEP -> DataType.TYPE_SLEEP_SEGMENT + WORKOUT -> DataType.TYPE_ACTIVITY_SEGMENT + NUTRITION -> DataType.TYPE_NUTRITION + else -> throw IllegalArgumentException("Unsupported dataType: $type") + } } - } - - /** - * Save a Nutrition measurement with calories, carbs, protein, fat, name and mealType - */ - private fun writeMeal(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - writeMealHC(call, result) - return + + private fun getField(type: String): Field { + return when (type) { + BODY_FAT_PERCENTAGE -> Field.FIELD_PERCENTAGE + HEIGHT -> Field.FIELD_HEIGHT + WEIGHT -> Field.FIELD_WEIGHT + STEPS -> Field.FIELD_STEPS + ACTIVE_ENERGY_BURNED -> Field.FIELD_CALORIES + HEART_RATE -> Field.FIELD_BPM + BODY_TEMPERATURE -> HealthFields.FIELD_BODY_TEMPERATURE + BLOOD_PRESSURE_SYSTOLIC -> HealthFields.FIELD_BLOOD_PRESSURE_SYSTOLIC + BLOOD_PRESSURE_DIASTOLIC -> HealthFields.FIELD_BLOOD_PRESSURE_DIASTOLIC + BLOOD_OXYGEN -> HealthFields.FIELD_OXYGEN_SATURATION + BLOOD_GLUCOSE -> HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL + MOVE_MINUTES -> Field.FIELD_DURATION + DISTANCE_DELTA -> Field.FIELD_DISTANCE + WATER -> Field.FIELD_VOLUME + SLEEP_ASLEEP -> Field.FIELD_SLEEP_SEGMENT_TYPE + SLEEP_AWAKE -> Field.FIELD_SLEEP_SEGMENT_TYPE + SLEEP_IN_BED -> Field.FIELD_SLEEP_SEGMENT_TYPE + SLEEP_LIGHT -> Field.FIELD_SLEEP_SEGMENT_TYPE + SLEEP_REM -> Field.FIELD_SLEEP_SEGMENT_TYPE + SLEEP_DEEP -> Field.FIELD_SLEEP_SEGMENT_TYPE + WORKOUT -> Field.FIELD_ACTIVITY + NUTRITION -> Field.FIELD_NUTRIENTS + else -> throw IllegalArgumentException("Unsupported dataType: $type") + } } - if (context == null) { - result.success(false) - return + private fun isIntField(dataSource: DataSource, unit: Field): Boolean { + val dataPoint = DataPoint.builder(dataSource).build() + val value = dataPoint.getValue(unit) + return value.format == Field.FORMAT_INT32 + } + + // / Extracts the (numeric) value from a Health Data Point + private fun getHealthDataValue(dataPoint: DataPoint, field: Field): Any { + val value = dataPoint.getValue(field) + // Conversion is needed because glucose is stored as mmoll in Google Fit; + // while mgdl is used for glucose in this plugin. + val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL + return when (value.format) { + Field.FORMAT_FLOAT -> + if (!isGlucose) value.asFloat() + else value.asFloat() * MMOLL_2_MGDL + Field.FORMAT_INT32 -> value.asInt() + Field.FORMAT_STRING -> value.asString() + else -> Log.e("Unsupported format:", value.format.toString()) + } } - val startTime = call.argument("startTime")!! - val endTime = call.argument("endTime")!! - val calories = call.argument("caloriesConsumed") - val carbs = call.argument("carbohydrates") as Double? - val protein = call.argument("protein") as Double? - val fat = call.argument("fatTotal") as Double? - val name = call.argument("name") - val mealType = call.argument("mealType")!! - - val dataType = DataType.TYPE_NUTRITION - - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - - val dataSource = DataSource.Builder() - .setDataType(dataType) - .setType(DataSource.TYPE_RAW) - .setDevice(Device.getLocalDevice(context!!.applicationContext)) - .setAppPackageName(context!!.applicationContext) - .build() - - val nutrients = mutableMapOf( - Field.NUTRIENT_CALORIES to calories?.toFloat() - ) - - if (carbs != null) { - nutrients[Field.NUTRIENT_TOTAL_CARBS] = carbs.toFloat() + /** Delete records of the given type in the time range */ + private fun delete(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + deleteHCData(call, result) + return + } + if (context == null) { + result.success(false) + return + } + + val type = call.argument("dataTypeKey")!! + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + + // Look up data type and unit for the type key + val dataType = keyToHealthDataType(type) + val field = getField(type) + + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + + val dataSource = + DataDeleteRequest.Builder() + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .addDataType(dataType) + .deleteAllSessions() + .build() + + val fitnessOptions = typesBuilder.build() + + try { + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions + ) + Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) + .deleteData(dataSource) + .addOnSuccessListener { + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "Dataset deleted successfully!" + ) + result.success(true) + } + .addOnFailureListener( + errHandler( + result, + "There was an error deleting the dataset" + ) + ) + } catch (e3: Exception) { + result.success(false) + } } - if (protein != null) { - nutrients[Field.NUTRIENT_PROTEIN] = protein.toFloat() + /** Save a Blood Pressure measurement with systolic and diastolic values */ + private fun writeBloodPressure(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + writeBloodPressureHC(call, result) + return + } + if (context == null) { + result.success(false) + return + } + + val dataType = HealthDataTypes.TYPE_BLOOD_PRESSURE + val systolic = call.argument("systolic")!! + val diastolic = call.argument("diastolic")!! + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + + val dataSource = + DataSource.Builder() + .setDataType(dataType) + .setType(DataSource.TYPE_RAW) + .setDevice( + Device.getLocalDevice( + context!!.applicationContext + ) + ) + .setAppPackageName(context!!.applicationContext) + .build() + + val builder = + DataPoint.builder(dataSource) + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .setField( + HealthFields.FIELD_BLOOD_PRESSURE_SYSTOLIC, + systolic + ) + .setField( + HealthFields.FIELD_BLOOD_PRESSURE_DIASTOLIC, + diastolic + ) + .build() + + val dataPoint = builder + val dataSet = DataSet.builder(dataSource).add(dataPoint).build() + + val fitnessOptions = typesBuilder.build() + try { + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions + ) + Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) + .insertData(dataSet) + .addOnSuccessListener { + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "Blood Pressure added successfully!" + ) + result.success(true) + } + .addOnFailureListener( + errHandler( + result, + "There was an error adding the blood pressure data!", + ), + ) + } catch (e3: Exception) { + result.success(false) + } } - if (fat != null) { - nutrients[Field.NUTRIENT_TOTAL_FAT] = fat.toFloat() + private fun writeMealHC(call: MethodCall, result: Result) { + val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) + val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) + val calories = call.argument("caloriesConsumed") + val carbs = call.argument("carbohydrates") as Double? + val protein = call.argument("protein") as Double? + val fat = call.argument("fatTotal") as Double? + val caffeine = call.argument("caffeine") as Double? + val name = call.argument("name") + val mealType = call.argument("mealType")!! + + scope.launch { + try { + val list = mutableListOf() + list.add( + NutritionRecord( + name = name, + energy = calories?.kilocalories, + totalCarbohydrate = carbs?.grams, + protein = protein?.grams, + totalFat = fat?.grams, + caffeine = caffeine?.grams, + startTime = startTime, + startZoneOffset = null, + endTime = endTime, + endZoneOffset = null, + mealType = + MapMealTypeToTypeHC[ + mealType] + ?: MEAL_TYPE_UNKNOWN, + ), + ) + healthConnectClient.insertRecords( + list, + ) + result.success(true) + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "[Health Connect] Meal was successfully added!" + ) + } catch (e: Exception) { + Log.w( + "FLUTTER_HEALTH::ERROR", + "[Health Connect] There was an error adding the meal", + ) + Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") + Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) + result.success(false) + } + } } - val dataBuilder = DataPoint.builder(dataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .setField(Field.FIELD_NUTRIENTS, nutrients) + /** Save a Nutrition measurement with calories, carbs, protein, fat, name and mealType */ + private fun writeMeal(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + writeMealHC(call, result) + return + } - if (name != null) { - dataBuilder.setField(Field.FIELD_FOOD_ITEM, name as String) - } + if (context == null) { + result.success(false) + return + } + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + val calories = call.argument("caloriesConsumed") + val carbs = call.argument("carbohydrates") as Double? + val protein = call.argument("protein") as Double? + val fat = call.argument("fatTotal") as Double? + val name = call.argument("name") + val mealType = call.argument("mealType")!! + + val dataType = DataType.TYPE_NUTRITION + + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + + val dataSource = + DataSource.Builder() + .setDataType(dataType) + .setType(DataSource.TYPE_RAW) + .setDevice( + Device.getLocalDevice( + context!!.applicationContext + ) + ) + .setAppPackageName(context!!.applicationContext) + .build() - dataBuilder.setField( - Field.FIELD_MEAL_TYPE, - MapMealTypeToType[mealType] ?: Field.MEAL_TYPE_UNKNOWN - ) + val nutrients = mutableMapOf(Field.NUTRIENT_CALORIES to calories?.toFloat()) + if (carbs != null) { + nutrients[Field.NUTRIENT_TOTAL_CARBS] = carbs.toFloat() + } - val dataPoint = dataBuilder.build() + if (protein != null) { + nutrients[Field.NUTRIENT_PROTEIN] = protein.toFloat() + } - val dataSet = DataSet.builder(dataSource) - .add(dataPoint) - .build() + if (fat != null) { + nutrients[Field.NUTRIENT_TOTAL_FAT] = fat.toFloat() + } - val fitnessOptions = typesBuilder.build() - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .insertData(dataSet) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Meal added successfully!") - result.success(true) + val dataBuilder = + DataPoint.builder(dataSource) + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .setField(Field.FIELD_NUTRIENTS, nutrients) + + if (name != null) { + dataBuilder.setField(Field.FIELD_FOOD_ITEM, name as String) } - .addOnFailureListener( - errHandler( - result, - "There was an error adding the meal data!" - ) + + dataBuilder.setField( + Field.FIELD_MEAL_TYPE, + MapMealTypeToType[mealType] ?: Field.MEAL_TYPE_UNKNOWN ) - } catch (e3: Exception) { - result.success(false) - } - } - - /** - * Save a data type in Google Fit - */ - private fun writeData(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - writeHCData(call, result) - return - } - if (context == null) { - result.success(false) - return - } - val type = call.argument("dataTypeKey")!! - val startTime = call.argument("startTime")!! - val endTime = call.argument("endTime")!! - val value = call.argument("value")!! - - // Look up data type and unit for the type key - val dataType = keyToHealthDataType(type) - val field = getField(type) - - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - - val dataSource = DataSource.Builder() - .setDataType(dataType) - .setType(DataSource.TYPE_RAW) - .setDevice(Device.getLocalDevice(context!!.applicationContext)) - .setAppPackageName(context!!.applicationContext) - .build() - - val builder = if (startTime == endTime) { - DataPoint.builder(dataSource) - .setTimestamp(startTime, TimeUnit.MILLISECONDS) - } else { - DataPoint.builder(dataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - } + val dataPoint = dataBuilder.build() - // Conversion is needed because glucose is stored as mmoll in Google Fit; - // while mgdl is used for glucose in this plugin. - val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL - val dataPoint = if (!isIntField(dataSource, field)) { - builder.setField(field, (if (!isGlucose) value else (value / MMOLL_2_MGDL).toFloat())) - .build() - } else { - builder.setField(field, value.toInt()).build() + val dataSet = DataSet.builder(dataSource).add(dataPoint).build() + + val fitnessOptions = typesBuilder.build() + try { + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions + ) + Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) + .insertData(dataSet) + .addOnSuccessListener { + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "Meal added successfully!" + ) + result.success(true) + } + .addOnFailureListener( + errHandler( + result, + "There was an error adding the meal data!" + ) + ) + } catch (e3: Exception) { + result.success(false) + } } - val dataSet = DataSet.builder(dataSource) - .add(dataPoint) - .build() + /** Save a data type in Google Fit */ + private fun writeData(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + writeHCData(call, result) + return + } + if (context == null) { + result.success(false) + return + } - if (dataType == DataType.TYPE_SLEEP_SEGMENT) { - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - } - val fitnessOptions = typesBuilder.build() - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .insertData(dataSet) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Dataset added successfully!") - result.success(true) - } - .addOnFailureListener(errHandler(result, "There was an error adding the dataset")) - } catch (e3: Exception) { - result.success(false) - } - } - - /** - * Save the blood oxygen saturation, in Google Fit with the supplemental flow rate, in HealthConnect without - */ - private fun writeBloodOxygen(call: MethodCall, result: Result) { - // Health Connect does not support supplemental flow rate, thus it is ignored - if (useHealthConnectIfAvailable && healthConnectAvailable) { - writeHCData(call, result) - return - } + val type = call.argument("dataTypeKey")!! + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + val value = call.argument("value")!! + + // Look up data type and unit for the type key + val dataType = keyToHealthDataType(type) + val field = getField(type) + + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + + val dataSource = + DataSource.Builder() + .setDataType(dataType) + .setType(DataSource.TYPE_RAW) + .setDevice( + Device.getLocalDevice( + context!!.applicationContext + ) + ) + .setAppPackageName(context!!.applicationContext) + .build() + + val builder = + if (startTime == endTime) { + DataPoint.builder(dataSource) + .setTimestamp( + startTime, + TimeUnit.MILLISECONDS + ) + } else { + DataPoint.builder(dataSource) + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + } - if (context == null) { - result.success(false) - return - } + // Conversion is needed because glucose is stored as mmoll in Google Fit; + // while mgdl is used for glucose in this plugin. + val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL + val dataPoint = + if (!isIntField(dataSource, field)) { + builder.setField( + field, + (if (!isGlucose) value + else + (value / + MMOLL_2_MGDL) + .toFloat()) + ) + .build() + } else { + builder.setField(field, value.toInt()).build() + } - val dataType = HealthDataTypes.TYPE_OXYGEN_SATURATION - val startTime = call.argument("startTime")!! - val endTime = call.argument("endTime")!! - val saturation = call.argument("value")!! - val flowRate = call.argument("flowRate")!! - - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - - val dataSource = DataSource.Builder() - .setDataType(dataType) - .setType(DataSource.TYPE_RAW) - .setDevice(Device.getLocalDevice(context!!.applicationContext)) - .setAppPackageName(context!!.applicationContext) - .build() - - val builder = if (startTime == endTime) { - DataPoint.builder(dataSource) - .setTimestamp(startTime, TimeUnit.MILLISECONDS) - } else { - DataPoint.builder(dataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - } + val dataSet = DataSet.builder(dataSource).add(dataPoint).build() - builder.setField(HealthFields.FIELD_SUPPLEMENTAL_OXYGEN_FLOW_RATE, flowRate) - builder.setField(HealthFields.FIELD_OXYGEN_SATURATION, saturation) - - val dataPoint = builder.build() - val dataSet = DataSet.builder(dataSource) - .add(dataPoint) - .build() - - val fitnessOptions = typesBuilder.build() - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .insertData(dataSet) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Blood Oxygen added successfully!") - result.success(true) - } - .addOnFailureListener( - errHandler( - result, - "There was an error adding the blood oxygen data!", - ), - ) - } catch (e3: Exception) { - result.success(false) - } - } - - /** - * Save a Workout session with options for distance and calories expended - */ - private fun writeWorkoutData(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - writeWorkoutHCData(call, result) - return - } - if (context == null) { - result.success(false) - return + if (dataType == DataType.TYPE_SLEEP_SEGMENT) { + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) + } + val fitnessOptions = typesBuilder.build() + try { + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions + ) + Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) + .insertData(dataSet) + .addOnSuccessListener { + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "Dataset added successfully!" + ) + result.success(true) + } + .addOnFailureListener( + errHandler( + result, + "There was an error adding the dataset" + ) + ) + } catch (e3: Exception) { + result.success(false) + } } - val type = call.argument("activityType")!! - val startTime = call.argument("startTime")!! - val endTime = call.argument("endTime")!! - val totalEnergyBurned = call.argument("totalEnergyBurned") - val totalDistance = call.argument("totalDistance") - - val activityType = getActivityType(type) - // Create the Activity Segment DataSource - val activitySegmentDataSource = DataSource.Builder() - .setAppPackageName(context!!.packageName) - .setDataType(DataType.TYPE_ACTIVITY_SEGMENT) - .setStreamName("FLUTTER_HEALTH - Activity") - .setType(DataSource.TYPE_RAW) - .build() - // Create the Activity Segment - val activityDataPoint = DataPoint.builder(activitySegmentDataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .setActivityField(Field.FIELD_ACTIVITY, activityType) - .build() - // Add DataPoint to DataSet - val activitySegments = DataSet.builder(activitySegmentDataSource) - .add(activityDataPoint) - .build() - - // If distance is provided - var distanceDataSet: DataSet? = null - if (totalDistance != null) { - // Create a data source - val distanceDataSource = DataSource.Builder() - .setAppPackageName(context!!.packageName) - .setDataType(DataType.TYPE_DISTANCE_DELTA) - .setStreamName("FLUTTER_HEALTH - Distance") - .setType(DataSource.TYPE_RAW) - .build() - - val distanceDataPoint = DataPoint.builder(distanceDataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .setField(Field.FIELD_DISTANCE, totalDistance.toFloat()) - .build() - // Create a data set - distanceDataSet = DataSet.builder(distanceDataSource) - .add(distanceDataPoint) - .build() - } - // If energyBurned is provided - var energyDataSet: DataSet? = null - if (totalEnergyBurned != null) { - // Create a data source - val energyDataSource = DataSource.Builder() - .setAppPackageName(context!!.packageName) - .setDataType(DataType.TYPE_CALORIES_EXPENDED) - .setStreamName("FLUTTER_HEALTH - Calories") - .setType(DataSource.TYPE_RAW) - .build() - - val energyDataPoint = DataPoint.builder(energyDataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .setField(Field.FIELD_CALORIES, totalEnergyBurned.toFloat()) - .build() - // Create a data set - energyDataSet = DataSet.builder(energyDataSource) - .add(energyDataPoint) - .build() - } + /** + * Save the blood oxygen saturation, in Google Fit with the supplemental flow rate, in + * HealthConnect without + */ + private fun writeBloodOxygen(call: MethodCall, result: Result) { + // Health Connect does not support supplemental flow rate, thus it is ignored + if (useHealthConnectIfAvailable && healthConnectAvailable) { + writeHCData(call, result) + return + } - // Finish session setup - val session = Session.Builder() - .setName(activityType) // TODO: Make a sensible name / allow user to set name - .setDescription("") - .setIdentifier(UUID.randomUUID().toString()) - .setActivity(activityType) - .setStartTime(startTime, TimeUnit.MILLISECONDS) - .setEndTime(endTime, TimeUnit.MILLISECONDS) - .build() - // Build a session and add the values provided - val sessionInsertRequestBuilder = SessionInsertRequest.Builder() - .setSession(session) - .addDataSet(activitySegments) - if (totalDistance != null) { - sessionInsertRequestBuilder.addDataSet(distanceDataSet!!) - } - if (totalEnergyBurned != null) { - sessionInsertRequestBuilder.addDataSet(energyDataSet!!) - } - val insertRequest = sessionInsertRequestBuilder.build() - - val fitnessOptionsBuilder = FitnessOptions.builder() - .addDataType(DataType.TYPE_ACTIVITY_SEGMENT, FitnessOptions.ACCESS_WRITE) - if (totalDistance != null) { - fitnessOptionsBuilder.addDataType( - DataType.TYPE_DISTANCE_DELTA, - FitnessOptions.ACCESS_WRITE, - ) - } - if (totalEnergyBurned != null) { - fitnessOptionsBuilder.addDataType( - DataType.TYPE_CALORIES_EXPENDED, - FitnessOptions.ACCESS_WRITE, - ) - } - val fitnessOptions = fitnessOptionsBuilder.build() - - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getSessionsClient( - context!!.applicationContext, - googleSignInAccount, - ) - .insertSession(insertRequest) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Workout was successfully added!") - result.success(true) - } - .addOnFailureListener(errHandler(result, "There was an error adding the workout")) - } catch (e: Exception) { - result.success(false) - } - } - - /** - * Get all datapoints of the DataType within the given time range - */ - private fun getData(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - getHCData(call, result) - return - } + if (context == null) { + result.success(false) + return + } - if (context == null) { - result.success(null) - return - } + val dataType = HealthDataTypes.TYPE_OXYGEN_SATURATION + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + val saturation = call.argument("value")!! + val flowRate = call.argument("flowRate")!! + + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + + val dataSource = + DataSource.Builder() + .setDataType(dataType) + .setType(DataSource.TYPE_RAW) + .setDevice( + Device.getLocalDevice( + context!!.applicationContext + ) + ) + .setAppPackageName(context!!.applicationContext) + .build() + + val builder = + if (startTime == endTime) { + DataPoint.builder(dataSource) + .setTimestamp( + startTime, + TimeUnit.MILLISECONDS + ) + } else { + DataPoint.builder(dataSource) + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + } - val type = call.argument("dataTypeKey")!! - val startTime = call.argument("startTime")!! - val endTime = call.argument("endTime")!! - // Look up data type and unit for the type key - val dataType = keyToHealthDataType(type) - val field = getField(type) - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType) - - // Add special cases for accessing workouts or sleep data. - if (dataType == DataType.TYPE_SLEEP_SEGMENT) { - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - } else if (dataType == DataType.TYPE_ACTIVITY_SEGMENT) { - typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) - .addDataType(DataType.TYPE_CALORIES_EXPENDED, FitnessOptions.ACCESS_READ) - .addDataType(DataType.TYPE_DISTANCE_DELTA, FitnessOptions.ACCESS_READ) - } - val fitnessOptions = typesBuilder.build() - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - // Handle data types - when (dataType) { - DataType.TYPE_SLEEP_SEGMENT -> { - // request to the sessions for sleep data - val request = SessionReadRequest.Builder() - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .enableServerQueries() - .readSessionsFromAllApps() - .includeSleepSessions() - .build() - Fitness.getSessionsClient(context!!.applicationContext, googleSignInAccount) - .readSession(request) - .addOnSuccessListener(threadPoolExecutor!!, sleepDataHandler(type, result)) - .addOnFailureListener( - errHandler( - result, - "There was an error getting the sleeping data!", - ), - ) - } - - DataType.TYPE_ACTIVITY_SEGMENT -> { - val readRequest: SessionReadRequest - val readRequestBuilder = SessionReadRequest.Builder() - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .enableServerQueries() - .readSessionsFromAllApps() - .includeActivitySessions() - .read(dataType) - .read(DataType.TYPE_CALORIES_EXPENDED) - - // If fine location is enabled, read distance data - if (ContextCompat.checkSelfPermission( - context!!.applicationContext, - android.Manifest.permission.ACCESS_FINE_LOCATION, - ) == PackageManager.PERMISSION_GRANTED - ) { - readRequestBuilder.read(DataType.TYPE_DISTANCE_DELTA) - } - readRequest = readRequestBuilder.build() - Fitness.getSessionsClient(context!!.applicationContext, googleSignInAccount) - .readSession(readRequest) - .addOnSuccessListener(threadPoolExecutor!!, workoutDataHandler(type, result)) - .addOnFailureListener( - errHandler( - result, - "There was an error getting the workout data!", - ), - ) - } - - else -> { - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .readData( - DataReadRequest.Builder() - .read(dataType) - .setTimeRange(startTime, endTime, TimeUnit.MILLISECONDS) - .build(), - ) - .addOnSuccessListener( - threadPoolExecutor!!, - dataHandler(dataType, field, result), - ) - .addOnFailureListener( - errHandler( - result, - "There was an error getting the data!", - ), - ) - } - } - } - - private fun dataHandler(dataType: DataType, field: Field, result: Result) = - OnSuccessListener { response: DataReadResponse -> - // / Fetch all data points for the specified DataType - val dataSet = response.getDataSet(dataType) - // / For each data point, extract the contents and send them to Flutter, along with date and unit. - val healthData = dataSet.dataPoints.mapIndexed { _, dataPoint -> - return@mapIndexed hashMapOf( - "value" to getHealthDataValue(dataPoint, field), - "date_from" to dataPoint.getStartTime(TimeUnit.MILLISECONDS), - "date_to" to dataPoint.getEndTime(TimeUnit.MILLISECONDS), - "source_name" to ( - dataPoint.originalDataSource.appPackageName - ?: ( - dataPoint.originalDataSource.device?.model - ?: "" + builder.setField(HealthFields.FIELD_SUPPLEMENTAL_OXYGEN_FLOW_RATE, flowRate) + builder.setField(HealthFields.FIELD_OXYGEN_SATURATION, saturation) + + val dataPoint = builder.build() + val dataSet = DataSet.builder(dataSource).add(dataPoint).build() + + val fitnessOptions = typesBuilder.build() + try { + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions ) - ), - "source_id" to dataPoint.originalDataSource.streamIdentifier, - ) - } - Handler(context!!.mainLooper).run { result.success(healthData) } + Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) + .insertData(dataSet) + .addOnSuccessListener { + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "Blood Oxygen added successfully!" + ) + result.success(true) + } + .addOnFailureListener( + errHandler( + result, + "There was an error adding the blood oxygen data!", + ), + ) + } catch (e3: Exception) { + result.success(false) + } } - private fun errHandler(result: Result, addMessage: String) = OnFailureListener { exception -> - Handler(context!!.mainLooper).run { result.success(null) } - Log.w("FLUTTER_HEALTH::ERROR", addMessage) - Log.w("FLUTTER_HEALTH::ERROR", exception.message ?: "unknown error") - Log.w("FLUTTER_HEALTH::ERROR", exception.stackTrace.toString()) - } - - private fun sleepDataHandler(type: String, result: Result) = - OnSuccessListener { response: SessionReadResponse -> - val healthData: MutableList> = mutableListOf() - for (session in response.sessions) { - // Return sleep time in Minutes if requested ASLEEP data - if (type == SLEEP_ASLEEP) { - healthData.add( - hashMapOf( - "value" to session.getEndTime(TimeUnit.MINUTES) - session.getStartTime( - TimeUnit.MINUTES, - ), - "date_from" to session.getStartTime(TimeUnit.MILLISECONDS), - "date_to" to session.getEndTime(TimeUnit.MILLISECONDS), - "unit" to "MINUTES", - "source_name" to session.appPackageName, - "source_id" to session.identifier, - ), - ) - } - - if (type == SLEEP_IN_BED) { - val dataSets = response.getDataSet(session) - - // If the sleep session has finer granularity sub-components, extract them: - if (dataSets.isNotEmpty()) { - for (dataSet in dataSets) { - for (dataPoint in dataSet.dataPoints) { - // searching OUT OF BED data - if (dataPoint.getValue(Field.FIELD_SLEEP_SEGMENT_TYPE) - .asInt() != 3 - ) { - healthData.add( - hashMapOf( - "value" to dataPoint.getEndTime(TimeUnit.MINUTES) - dataPoint.getStartTime( - TimeUnit.MINUTES, - ), - "date_from" to dataPoint.getStartTime(TimeUnit.MILLISECONDS), - "date_to" to dataPoint.getEndTime(TimeUnit.MILLISECONDS), - "unit" to "MINUTES", - "source_name" to ( - dataPoint.originalDataSource.appPackageName - ?: ( - dataPoint.originalDataSource.device?.model - ?: "unknown" - ) - ), - "source_id" to dataPoint.originalDataSource.streamIdentifier, - ), - ) - } - } - } - } else { - healthData.add( - hashMapOf( - "value" to session.getEndTime(TimeUnit.MINUTES) - session.getStartTime( - TimeUnit.MINUTES, - ), - "date_from" to session.getStartTime(TimeUnit.MILLISECONDS), - "date_to" to session.getEndTime(TimeUnit.MILLISECONDS), - "unit" to "MINUTES", - "source_name" to session.appPackageName, - "source_id" to session.identifier, - ), + /** Save a Workout session with options for distance and calories expended */ + private fun writeWorkoutData(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + writeWorkoutHCData(call, result) + return + } + if (context == null) { + result.success(false) + return + } + + val type = call.argument("activityType")!! + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + val totalEnergyBurned = call.argument("totalEnergyBurned") + val totalDistance = call.argument("totalDistance") + + val activityType = getActivityType(type) + // Create the Activity Segment DataSource + val activitySegmentDataSource = + DataSource.Builder() + .setAppPackageName(context!!.packageName) + .setDataType(DataType.TYPE_ACTIVITY_SEGMENT) + .setStreamName("FLUTTER_HEALTH - Activity") + .setType(DataSource.TYPE_RAW) + .build() + // Create the Activity Segment + val activityDataPoint = + DataPoint.builder(activitySegmentDataSource) + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .setActivityField( + Field.FIELD_ACTIVITY, + activityType + ) + .build() + // Add DataPoint to DataSet + val activitySegments = + DataSet.builder(activitySegmentDataSource) + .add(activityDataPoint) + .build() + + // If distance is provided + var distanceDataSet: DataSet? = null + if (totalDistance != null) { + // Create a data source + val distanceDataSource = + DataSource.Builder() + .setAppPackageName(context!!.packageName) + .setDataType(DataType.TYPE_DISTANCE_DELTA) + .setStreamName("FLUTTER_HEALTH - Distance") + .setType(DataSource.TYPE_RAW) + .build() + + val distanceDataPoint = + DataPoint.builder(distanceDataSource) + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .setField( + Field.FIELD_DISTANCE, + totalDistance.toFloat() + ) + .build() + // Create a data set + distanceDataSet = + DataSet.builder(distanceDataSource) + .add(distanceDataPoint) + .build() + } + // If energyBurned is provided + var energyDataSet: DataSet? = null + if (totalEnergyBurned != null) { + // Create a data source + val energyDataSource = + DataSource.Builder() + .setAppPackageName(context!!.packageName) + .setDataType( + DataType.TYPE_CALORIES_EXPENDED + ) + .setStreamName("FLUTTER_HEALTH - Calories") + .setType(DataSource.TYPE_RAW) + .build() + + val energyDataPoint = + DataPoint.builder(energyDataSource) + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .setField( + Field.FIELD_CALORIES, + totalEnergyBurned.toFloat() + ) + .build() + // Create a data set + energyDataSet = + DataSet.builder(energyDataSource) + .add(energyDataPoint) + .build() + } + + // Finish session setup + val session = + Session.Builder() + .setName( + activityType + ) // TODO: Make a sensible name / allow user to set + // name + .setDescription("") + .setIdentifier(UUID.randomUUID().toString()) + .setActivity(activityType) + .setStartTime(startTime, TimeUnit.MILLISECONDS) + .setEndTime(endTime, TimeUnit.MILLISECONDS) + .build() + // Build a session and add the values provided + val sessionInsertRequestBuilder = + SessionInsertRequest.Builder() + .setSession(session) + .addDataSet(activitySegments) + if (totalDistance != null) { + sessionInsertRequestBuilder.addDataSet(distanceDataSet!!) + } + if (totalEnergyBurned != null) { + sessionInsertRequestBuilder.addDataSet(energyDataSet!!) + } + val insertRequest = sessionInsertRequestBuilder.build() + + val fitnessOptionsBuilder = + FitnessOptions.builder() + .addDataType( + DataType.TYPE_ACTIVITY_SEGMENT, + FitnessOptions.ACCESS_WRITE + ) + if (totalDistance != null) { + fitnessOptionsBuilder.addDataType( + DataType.TYPE_DISTANCE_DELTA, + FitnessOptions.ACCESS_WRITE, ) - } } + if (totalEnergyBurned != null) { + fitnessOptionsBuilder.addDataType( + DataType.TYPE_CALORIES_EXPENDED, + FitnessOptions.ACCESS_WRITE, + ) + } + val fitnessOptions = fitnessOptionsBuilder.build() - if (type == SLEEP_AWAKE) { - val dataSets = response.getDataSet(session) - for (dataSet in dataSets) { - for (dataPoint in dataSet.dataPoints) { - // searching SLEEP AWAKE data - if (dataPoint.getValue(Field.FIELD_SLEEP_SEGMENT_TYPE).asInt() == 1) { - healthData.add( - hashMapOf( - "value" to dataPoint.getEndTime(TimeUnit.MINUTES) - dataPoint.getStartTime( - TimeUnit.MINUTES, - ), - "date_from" to dataPoint.getStartTime(TimeUnit.MILLISECONDS), - "date_to" to dataPoint.getEndTime(TimeUnit.MILLISECONDS), - "unit" to "MINUTES", - "source_name" to ( - dataPoint.originalDataSource.appPackageName - ?: ( - dataPoint.originalDataSource.device?.model - ?: "unknown" - ) - ), - "source_id" to dataPoint.originalDataSource.streamIdentifier, - ), - ) - } - } - } + try { + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions + ) + Fitness.getSessionsClient( + context!!.applicationContext, + googleSignInAccount, + ) + .insertSession(insertRequest) + .addOnSuccessListener { + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "Workout was successfully added!" + ) + result.success(true) + } + .addOnFailureListener( + errHandler( + result, + "There was an error adding the workout" + ) + ) + } catch (e: Exception) { + result.success(false) } - } - Handler(context!!.mainLooper).run { result.success(healthData) } } - private fun workoutDataHandler(type: String, result: Result) = - OnSuccessListener { response: SessionReadResponse -> - val healthData: MutableList> = mutableListOf() - for (session in response.sessions) { - // Look for calories and distance if they - var totalEnergyBurned = 0.0 - var totalDistance = 0.0 - for (dataSet in response.getDataSet(session)) { - if (dataSet.dataType == DataType.TYPE_CALORIES_EXPENDED) { - for (dataPoint in dataSet.dataPoints) { - totalEnergyBurned += dataPoint.getValue(Field.FIELD_CALORIES).toString() - .toDouble() + /** Get all datapoints of the DataType within the given time range */ + private fun getData(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + getHCData(call, result) + return + } + + if (context == null) { + result.success(null) + return + } + + val type = call.argument("dataTypeKey")!! + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + val includeManualEntry = call.argument("includeManualEntry")!! + // Look up data type and unit for the type key + val dataType = keyToHealthDataType(type) + val field = getField(type) + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType) + + // Add special cases for accessing workouts or sleep data. + if (dataType == DataType.TYPE_SLEEP_SEGMENT) { + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) + } else if (dataType == DataType.TYPE_ACTIVITY_SEGMENT) { + typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) + .addDataType( + DataType.TYPE_CALORIES_EXPENDED, + FitnessOptions.ACCESS_READ + ) + .addDataType( + DataType.TYPE_DISTANCE_DELTA, + FitnessOptions.ACCESS_READ + ) + } + val fitnessOptions = typesBuilder.build() + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions + ) + // Handle data types + when (dataType) { + DataType.TYPE_SLEEP_SEGMENT -> { + // request to the sessions for sleep data + val request = + SessionReadRequest.Builder() + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .enableServerQueries() + .readSessionsFromAllApps() + .includeSleepSessions() + .build() + Fitness.getSessionsClient( + context!!.applicationContext, + googleSignInAccount + ) + .readSession(request) + .addOnSuccessListener( + threadPoolExecutor!!, + sleepDataHandler(type, result) + ) + .addOnFailureListener( + errHandler( + result, + "There was an error getting the sleeping data!", + ), + ) } - } - if (dataSet.dataType == DataType.TYPE_DISTANCE_DELTA) { - for (dataPoint in dataSet.dataPoints) { - totalDistance += dataPoint.getValue(Field.FIELD_DISTANCE).toString() - .toDouble() + DataType.TYPE_ACTIVITY_SEGMENT -> { + val readRequest: SessionReadRequest + val readRequestBuilder = + SessionReadRequest.Builder() + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .enableServerQueries() + .readSessionsFromAllApps() + .includeActivitySessions() + .read(dataType) + .read( + DataType.TYPE_CALORIES_EXPENDED + ) + + // If fine location is enabled, read distance data + if (ContextCompat.checkSelfPermission( + context!!.applicationContext, + android.Manifest.permission + .ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + ) { + readRequestBuilder.read(DataType.TYPE_DISTANCE_DELTA) + } + readRequest = readRequestBuilder.build() + Fitness.getSessionsClient( + context!!.applicationContext, + googleSignInAccount + ) + .readSession(readRequest) + .addOnSuccessListener( + threadPoolExecutor!!, + workoutDataHandler(type, result) + ) + .addOnFailureListener( + errHandler( + result, + "There was an error getting the workout data!", + ), + ) + } + else -> { + Fitness.getHistoryClient( + context!!.applicationContext, + googleSignInAccount + ) + .readData( + DataReadRequest.Builder() + .read(dataType) + .setTimeRange( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .build(), + ) + .addOnSuccessListener( + threadPoolExecutor!!, + dataHandler( + dataType, + field, + includeManualEntry, + result + ), + ) + .addOnFailureListener( + errHandler( + result, + "There was an error getting the data!", + ), + ) } - } } - healthData.add( - hashMapOf( - "workoutActivityType" to ( - workoutTypeMap.filterValues { it == session.activity }.keys.firstOrNull() - ?: "OTHER" - ), - "totalEnergyBurned" to if (totalEnergyBurned == 0.0) null else totalEnergyBurned, - "totalEnergyBurnedUnit" to "KILOCALORIE", - "totalDistance" to if (totalDistance == 0.0) null else totalDistance, - "totalDistanceUnit" to "METER", - "date_from" to session.getStartTime(TimeUnit.MILLISECONDS), - "date_to" to session.getEndTime(TimeUnit.MILLISECONDS), - "unit" to "MINUTES", - "source_name" to session.appPackageName, - "source_id" to session.identifier, - ), - ) - } - Handler(context!!.mainLooper).run { result.success(healthData) } } - private fun callToHealthTypes(call: MethodCall): FitnessOptions { - val typesBuilder = FitnessOptions.builder() - val args = call.arguments as HashMap<*, *> - val types = (args["types"] as? ArrayList<*>)?.filterIsInstance() - val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance() - - assert(types != null) - assert(permissions != null) - assert(types!!.count() == permissions!!.count()) - - for ((i, typeKey) in types.withIndex()) { - val access = permissions[i] - val dataType = keyToHealthDataType(typeKey) - when (access) { - 0 -> typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_READ) - 1 -> typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - 2 -> { - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_READ) - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - } - - else -> throw IllegalArgumentException("Unknown access type $access") - } - if (typeKey == SLEEP_ASLEEP || typeKey == SLEEP_AWAKE || typeKey == SLEEP_IN_BED) { - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - when (access) { - 0 -> typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - 1 -> typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_WRITE) - 2 -> { - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_WRITE) - } + private fun getIntervalData(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + getAggregateHCData(call, result) + return + } - else -> throw IllegalArgumentException("Unknown access type $access") + if (context == null) { + result.success(null) + return } - } - if (typeKey == WORKOUT) { - when (access) { - 0 -> typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) - 1 -> typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_WRITE) - 2 -> { - typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) - typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_WRITE) - } - else -> throw IllegalArgumentException("Unknown access type $access") + val type = call.argument("dataTypeKey")!! + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + val interval = call.argument("interval")!! + val includeManualEntry = call.argument("includeManualEntry")!! + + // Look up data type and unit for the type key + val dataType = keyToHealthDataType(type) + val field = getField(type) + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType) + if (dataType == DataType.TYPE_SLEEP_SEGMENT) { + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) } - } - } - return typesBuilder.build() - } + val fitnessOptions = typesBuilder.build() + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions + ) - private fun hasPermissions(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - hasPermissionsHC(call, result) - return - } - if (context == null) { - result.success(false) - return + Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) + .readData( + DataReadRequest.Builder() + .aggregate(dataType) + .bucketByTime( + interval, + TimeUnit.SECONDS + ) + .setTimeRange( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .build() + ) + .addOnSuccessListener( + threadPoolExecutor!!, + intervalDataHandler( + dataType, + field, + includeManualEntry, + result + ) + ) + .addOnFailureListener( + errHandler( + result, + "There was an error getting the interval data!" + ) + ) } - val optionsToRegister = callToHealthTypes(call) + private fun getAggregateData(call: MethodCall, result: Result) { + if (context == null) { + result.success(null) + return + } - val isGranted = GoogleSignIn.hasPermissions( - GoogleSignIn.getLastSignedInAccount(context!!), - optionsToRegister, - ) + val types = call.argument>("dataTypeKeys")!! + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + val activitySegmentDuration = call.argument("activitySegmentDuration")!! + val includeManualEntry = call.argument("includeManualEntry")!! - result?.success(isGranted) - } + val typesBuilder = FitnessOptions.builder() + for (type in types) { + val dataType = keyToHealthDataType(type) + typesBuilder.addDataType(dataType) + } + val fitnessOptions = typesBuilder.build() + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions + ) + + val readWorkoutsRequest = + DataReadRequest.Builder() + .bucketByActivitySegment( + activitySegmentDuration, + TimeUnit.SECONDS + ) + .setTimeRange( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + + for (type in types) { + val dataType = keyToHealthDataType(type) + readWorkoutsRequest.aggregate(dataType) + } - /** - * Requests authorization for the HealthDataTypes - * with the the READ or READ_WRITE permission type. - */ - private fun requestAuthorization(call: MethodCall, result: Result) { - if (context == null) { - result.success(false) - return + Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) + .readData(readWorkoutsRequest.build()) + .addOnSuccessListener( + threadPoolExecutor!!, + aggregateDataHandler(includeManualEntry, result) + ) + .addOnFailureListener( + errHandler( + result, + "There was an error getting the aggregate data!" + ) + ) } - mResult = result - if (useHealthConnectIfAvailable && healthConnectAvailable) { - requestAuthorizationHC(call, result) - return + private fun dataHandler( + dataType: DataType, + field: Field, + includeManualEntry: Boolean, + result: Result + ) = OnSuccessListener { response: DataReadResponse -> + // / Fetch all data points for the specified DataType + val dataSet = response.getDataSet(dataType) + /// For each data point, extract the contents and send them to Flutter, along with + // date and unit. + var dataPoints = dataSet.dataPoints + if (!includeManualEntry) { + dataPoints = + dataPoints.filterIndexed { _, dataPoint -> + !dataPoint.originalDataSource.streamName.contains( + "user_input" + ) + } + } + // For each data point, extract the contents and send them to Flutter, along with + // date and unit. + val healthData = + dataPoints.mapIndexed { _, dataPoint -> + return@mapIndexed hashMapOf( + "value" to + getHealthDataValue( + dataPoint, + field + ), + "date_from" to + dataPoint.getStartTime( + TimeUnit.MILLISECONDS + ), + "date_to" to + dataPoint.getEndTime( + TimeUnit.MILLISECONDS + ), + "source_name" to + (dataPoint.originalDataSource + .appPackageName + ?: (dataPoint.originalDataSource + .device + ?.model + ?: "")), + "source_id" to + dataPoint.originalDataSource + .streamIdentifier, + ) + } + Handler(context!!.mainLooper).run { result.success(healthData) } } - val optionsToRegister = callToHealthTypes(call) - - // Set to false due to bug described in https://github.com/cph-cachet/flutter-plugins/issues/640#issuecomment-1366830132 - val isGranted = false - - // If not granted then ask for permission - if (!isGranted && activity != null) { - GoogleSignIn.requestPermissions( - activity!!, - GOOGLE_FIT_PERMISSIONS_REQUEST_CODE, - GoogleSignIn.getLastSignedInAccount(context!!), - optionsToRegister, - ) - } else { // / Permission already granted - result?.success(true) + private fun errHandler(result: Result, addMessage: String) = + OnFailureListener { exception -> + Handler(context!!.mainLooper).run { result.success(null) } + Log.w("FLUTTER_HEALTH::ERROR", addMessage) + Log.w("FLUTTER_HEALTH::ERROR", exception.message ?: "unknown error") + Log.w("FLUTTER_HEALTH::ERROR", exception.stackTrace.toString()) + } + + private fun sleepDataHandler(type: String, result: Result) = + OnSuccessListener { response: SessionReadResponse -> + val healthData: MutableList> = mutableListOf() + for (session in response.sessions) { + // Return sleep time in Minutes if requested ASLEEP data + if (type == SLEEP_ASLEEP) { + healthData.add( + hashMapOf( + "value" to + session.getEndTime( + TimeUnit.MINUTES + ) - + session.getStartTime( + TimeUnit.MINUTES, + ), + "date_from" to + session.getStartTime( + TimeUnit.MILLISECONDS + ), + "date_to" to + session.getEndTime( + TimeUnit.MILLISECONDS + ), + "unit" to "MINUTES", + "source_name" to + session.appPackageName, + "source_id" to + session.identifier, + ), + ) + } + + if (type == SLEEP_IN_BED) { + val dataSets = response.getDataSet(session) + + // If the sleep session has finer granularity + // sub-components, extract them: + if (dataSets.isNotEmpty()) { + for (dataSet in dataSets) { + for (dataPoint in + dataSet.dataPoints) { + // searching OUT OF BED data + if (dataPoint.getValue( + Field.FIELD_SLEEP_SEGMENT_TYPE + ) + .asInt() != + 3 + ) { + healthData.add( + hashMapOf( + "value" to + dataPoint.getEndTime( + TimeUnit.MINUTES + ) - + dataPoint.getStartTime( + TimeUnit.MINUTES, + ), + "date_from" to + dataPoint.getStartTime( + TimeUnit.MILLISECONDS + ), + "date_to" to + dataPoint.getEndTime( + TimeUnit.MILLISECONDS + ), + "unit" to + "MINUTES", + "source_name" to + (dataPoint.originalDataSource + .appPackageName + ?: (dataPoint.originalDataSource + .device + ?.model + ?: "unknown")), + "source_id" to + dataPoint.originalDataSource + .streamIdentifier, + ), + ) + } + } + } + } else { + healthData.add( + hashMapOf( + "value" to + session.getEndTime( + TimeUnit.MINUTES + ) - + session.getStartTime( + TimeUnit.MINUTES, + ), + "date_from" to + session.getStartTime( + TimeUnit.MILLISECONDS + ), + "date_to" to + session.getEndTime( + TimeUnit.MILLISECONDS + ), + "unit" to + "MINUTES", + "source_name" to + session.appPackageName, + "source_id" to + session.identifier, + ), + ) + } + } + + if (type == SLEEP_AWAKE) { + val dataSets = response.getDataSet(session) + for (dataSet in dataSets) { + for (dataPoint in dataSet.dataPoints) { + // searching SLEEP AWAKE data + if (dataPoint.getValue( + Field.FIELD_SLEEP_SEGMENT_TYPE + ) + .asInt() == + 1 + ) { + healthData.add( + hashMapOf( + "value" to + dataPoint.getEndTime( + TimeUnit.MINUTES + ) - + dataPoint.getStartTime( + TimeUnit.MINUTES, + ), + "date_from" to + dataPoint.getStartTime( + TimeUnit.MILLISECONDS + ), + "date_to" to + dataPoint.getEndTime( + TimeUnit.MILLISECONDS + ), + "unit" to + "MINUTES", + "source_name" to + (dataPoint.originalDataSource + .appPackageName + ?: (dataPoint.originalDataSource + .device + ?.model + ?: "unknown")), + "source_id" to + dataPoint.originalDataSource + .streamIdentifier, + ), + ) + } + } + } + } + } + Handler(context!!.mainLooper).run { result.success(healthData) } + } + + private fun intervalDataHandler( + dataType: DataType, + field: Field, + includeManualEntry: Boolean, + result: Result + ) = OnSuccessListener { response: DataReadResponse -> + val healthData = mutableListOf>() + for (bucket in response.buckets) { + /// Fetch all data points for the specified DataType + // val dataSet = response.getDataSet(dataType) + for (dataSet in bucket.dataSets) { + /// For each data point, extract the contents and send them to + // Flutter, along with + // date and unit. + var dataPoints = dataSet.dataPoints + if (!includeManualEntry) { + dataPoints = + dataPoints.filterIndexed { _, dataPoint -> + !dataPoint.originalDataSource + .streamName + .contains( + "user_input" + ) + } + } + for (dataPoint in dataPoints) { + for (field in dataPoint.dataType.fields) { + val healthDataItems = + dataPoints.mapIndexed { _, dataPoint + -> + return@mapIndexed hashMapOf( + "value" to + getHealthDataValue( + dataPoint, + field + ), + "date_from" to + dataPoint.getStartTime( + TimeUnit.MILLISECONDS + ), + "date_to" to + dataPoint.getEndTime( + TimeUnit.MILLISECONDS + ), + "source_name" to + (dataPoint.originalDataSource + .appPackageName + ?: (dataPoint.originalDataSource + .device + ?.model + ?: "")), + "source_id" to + dataPoint.originalDataSource + .streamIdentifier, + "is_manual_entry" to + dataPoint.originalDataSource + .streamName + .contains( + "user_input" + ) + ) + } + healthData.addAll(healthDataItems) + } + } + } + } + Handler(context!!.mainLooper).run { result.success(healthData) } + } + + private fun aggregateDataHandler(includeManualEntry: Boolean, result: Result) = + OnSuccessListener { response: DataReadResponse -> + val healthData = mutableListOf>() + for (bucket in response.buckets) { + var sourceName: Any = "" + var sourceId: Any = "" + var isManualEntry: Any = false + var totalSteps: Any = 0 + var totalDistance: Any = 0 + var totalEnergyBurned: Any = 0 + /// Fetch all data points for the specified DataType + for (dataSet in bucket.dataSets) { + /// For each data point, extract the contents and + // send them to Flutter, + // along with date and unit. + var dataPoints = dataSet.dataPoints + if (!includeManualEntry) { + dataPoints = + dataPoints.filterIndexed { + _, + dataPoint -> + !dataPoint.originalDataSource + .streamName + .contains( + "user_input" + ) + } + } + for (dataPoint in dataPoints) { + sourceName = + (dataPoint.originalDataSource + .appPackageName + ?: (dataPoint.originalDataSource + .device + ?.model + ?: "")) + sourceId = + dataPoint.originalDataSource + .streamIdentifier + isManualEntry = + dataPoint.originalDataSource + .streamName + .contains( + "user_input" + ) + for (field in dataPoint.dataType.fields) { + when (field) { + getField(STEPS) -> { + totalSteps = + getHealthDataValue( + dataPoint, + field + ) + } + getField( + DISTANCE_DELTA + ) -> { + totalDistance = + getHealthDataValue( + dataPoint, + field + ) + } + getField( + ACTIVE_ENERGY_BURNED + ) -> { + totalEnergyBurned = + getHealthDataValue( + dataPoint, + field + ) + } + } + } + } + } + val healthDataItems = + hashMapOf( + "value" to + bucket.getEndTime( + TimeUnit.MINUTES + ) - + bucket.getStartTime( + TimeUnit.MINUTES + ), + "date_from" to + bucket.getStartTime( + TimeUnit.MILLISECONDS + ), + "date_to" to + bucket.getEndTime( + TimeUnit.MILLISECONDS + ), + "source_name" to sourceName, + "source_id" to sourceId, + "is_manual_entry" to + isManualEntry, + "workout_type" to + bucket.activity + .toLowerCase(), + "total_steps" to totalSteps, + "total_distance" to + totalDistance, + "total_energy_burned" to + totalEnergyBurned + ) + healthData.add(healthDataItems) + } + Handler(context!!.mainLooper).run { result.success(healthData) } + } + + private fun workoutDataHandler(type: String, result: Result) = + OnSuccessListener { response: SessionReadResponse -> + val healthData: MutableList> = mutableListOf() + for (session in response.sessions) { + // Look for calories and distance if they + var totalEnergyBurned = 0.0 + var totalDistance = 0.0 + for (dataSet in response.getDataSet(session)) { + if (dataSet.dataType == + DataType.TYPE_CALORIES_EXPENDED + ) { + for (dataPoint in dataSet.dataPoints) { + totalEnergyBurned += + dataPoint.getValue( + Field.FIELD_CALORIES + ) + .toString() + .toDouble() + } + } + if (dataSet.dataType == DataType.TYPE_DISTANCE_DELTA + ) { + for (dataPoint in dataSet.dataPoints) { + totalDistance += + dataPoint.getValue( + Field.FIELD_DISTANCE + ) + .toString() + .toDouble() + } + } + } + healthData.add( + hashMapOf( + "workoutActivityType" to + (workoutTypeMap + .filterValues { + it == + session.activity + } + .keys + .firstOrNull() + ?: "OTHER"), + "totalEnergyBurned" to + if (totalEnergyBurned == + 0.0 + ) + null + else + totalEnergyBurned, + "totalEnergyBurnedUnit" to + "KILOCALORIE", + "totalDistance" to + if (totalDistance == + 0.0 + ) + null + else + totalDistance, + "totalDistanceUnit" to + "METER", + "date_from" to + session.getStartTime( + TimeUnit.MILLISECONDS + ), + "date_to" to + session.getEndTime( + TimeUnit.MILLISECONDS + ), + "unit" to "MINUTES", + "source_name" to + session.appPackageName, + "source_id" to + session.identifier, + ), + ) + } + Handler(context!!.mainLooper).run { result.success(healthData) } + } + + private fun callToHealthTypes(call: MethodCall): FitnessOptions { + val typesBuilder = FitnessOptions.builder() + val args = call.arguments as HashMap<*, *> + val types = (args["types"] as? ArrayList<*>)?.filterIsInstance() + val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance() + + assert(types != null) + assert(permissions != null) + assert(types!!.count() == permissions!!.count()) + + for ((i, typeKey) in types.withIndex()) { + val access = permissions[i] + val dataType = keyToHealthDataType(typeKey) + when (access) { + 0 -> typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_READ) + 1 -> typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + 2 -> { + typesBuilder.addDataType( + dataType, + FitnessOptions.ACCESS_READ + ) + typesBuilder.addDataType( + dataType, + FitnessOptions.ACCESS_WRITE + ) + } + else -> + throw IllegalArgumentException( + "Unknown access type $access" + ) + } + if (typeKey == SLEEP_ASLEEP || + typeKey == SLEEP_AWAKE || + typeKey == SLEEP_IN_BED + ) { + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) + when (access) { + 0 -> + typesBuilder.accessSleepSessions( + FitnessOptions.ACCESS_READ + ) + 1 -> + typesBuilder.accessSleepSessions( + FitnessOptions.ACCESS_WRITE + ) + 2 -> { + typesBuilder.accessSleepSessions( + FitnessOptions.ACCESS_READ + ) + typesBuilder.accessSleepSessions( + FitnessOptions.ACCESS_WRITE + ) + } + else -> + throw IllegalArgumentException( + "Unknown access type $access" + ) + } + } + if (typeKey == WORKOUT) { + when (access) { + 0 -> + typesBuilder.accessActivitySessions( + FitnessOptions.ACCESS_READ + ) + 1 -> + typesBuilder.accessActivitySessions( + FitnessOptions.ACCESS_WRITE + ) + 2 -> { + typesBuilder.accessActivitySessions( + FitnessOptions.ACCESS_READ + ) + typesBuilder.accessActivitySessions( + FitnessOptions.ACCESS_WRITE + ) + } + else -> + throw IllegalArgumentException( + "Unknown access type $access" + ) + } + } + } + return typesBuilder.build() } - } - - /** - * Revokes access to Google Fit using the `disableFit`-method. - * - * Note: Using the `revokeAccess` creates a bug on android - * when trying to reapply for permissions afterwards, hence - * `disableFit` was used. - */ - private fun revokePermissions(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - result.notImplemented() - return + + private fun hasPermissions(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + hasPermissionsHC(call, result) + return + } + if (context == null) { + result.success(false) + return + } + + val optionsToRegister = callToHealthTypes(call) + + val isGranted = + GoogleSignIn.hasPermissions( + GoogleSignIn.getLastSignedInAccount(context!!), + optionsToRegister, + ) + + result?.success(isGranted) } - if (context == null) { - result.success(false) - return + + /** + * Requests authorization for the HealthDataTypes with the the READ or READ_WRITE permission + * type. + */ + private fun requestAuthorization(call: MethodCall, result: Result) { + if (context == null) { + result.success(false) + return + } + mResult = result + + if (useHealthConnectIfAvailable && healthConnectAvailable) { + requestAuthorizationHC(call, result) + return + } + + val optionsToRegister = callToHealthTypes(call) + + // Set to false due to bug described in + // https://github.com/cph-cachet/flutter-plugins/issues/640#issuecomment-1366830132 + val isGranted = false + + // If not granted then ask for permission + if (!isGranted && activity != null) { + GoogleSignIn.requestPermissions( + activity!!, + GOOGLE_FIT_PERMISSIONS_REQUEST_CODE, + GoogleSignIn.getLastSignedInAccount(context!!), + optionsToRegister, + ) + } else { // / Permission already granted + result?.success(true) + } } - Fitness.getConfigClient(activity!!, GoogleSignIn.getLastSignedInAccount(context!!)!!) - .disableFit() - .addOnSuccessListener { - Log.i("Health", "Disabled Google Fit") - result.success(true) - } - .addOnFailureListener { e -> - Log.w("Health", "There was an error disabling Google Fit", e) - result.success(false) - } - } - - private fun getTotalStepsInInterval(call: MethodCall, result: Result) { - val start = call.argument("startTime")!! - val end = call.argument("endTime")!! - - if (useHealthConnectIfAvailable && healthConnectAvailable) { - getStepsHealthConnect(start, end, result) - return + + /** + * Revokes access to Google Fit using the `disableFit`-method. + * + * Note: Using the `revokeAccess` creates a bug on android when trying to reapply for + * permissions afterwards, hence `disableFit` was used. + */ + private fun revokePermissions(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + result.notImplemented() + return + } + if (context == null) { + result.success(false) + return + } + Fitness.getConfigClient( + activity!!, + GoogleSignIn.getLastSignedInAccount(context!!)!! + ) + .disableFit() + .addOnSuccessListener { + Log.i("Health", "Disabled Google Fit") + result.success(true) + } + .addOnFailureListener { e -> + Log.w( + "Health", + "There was an error disabling Google Fit", + e + ) + result.success(false) + } } - val context = context ?: return - - val stepsDataType = keyToHealthDataType(STEPS) - val aggregatedDataType = keyToHealthDataType(AGGREGATE_STEP_COUNT) - - val fitnessOptions = FitnessOptions.builder() - .addDataType(stepsDataType) - .addDataType(aggregatedDataType) - .build() - val gsa = GoogleSignIn.getAccountForExtension(context, fitnessOptions) - - val ds = DataSource.Builder() - .setAppPackageName("com.google.android.gms") - .setDataType(stepsDataType) - .setType(DataSource.TYPE_DERIVED) - .setStreamName("estimated_steps") - .build() - - val duration = (end - start).toInt() - - val request = DataReadRequest.Builder() - .aggregate(ds) - .bucketByTime(duration, TimeUnit.MILLISECONDS) - .setTimeRange(start, end, TimeUnit.MILLISECONDS) - .build() - - Fitness.getHistoryClient(context, gsa).readData(request) - .addOnFailureListener( - errHandler( - result, - "There was an error getting the total steps in the interval!", - ), - ) - .addOnSuccessListener( - threadPoolExecutor!!, - getStepsInRange(start, end, aggregatedDataType, result), - ) - } - - private fun getStepsHealthConnect(start: Long, end: Long, result: Result) = scope.launch { - try { - val startInstant = Instant.ofEpochMilli(start) - val endInstant = Instant.ofEpochMilli(end) - val response = healthConnectClient.aggregate( - AggregateRequest( - metrics = setOf(StepsRecord.COUNT_TOTAL), - timeRangeFilter = TimeRangeFilter.between(startInstant, endInstant), - ), - ) - // The result may be null if no data is available in the time range. - val stepsInInterval = response[StepsRecord.COUNT_TOTAL] ?: 0L - Log.i("FLUTTER_HEALTH::SUCCESS", "returning $stepsInInterval steps") - result.success(stepsInInterval) - } catch (e: Exception) { - Log.i("FLUTTER_HEALTH::ERROR", "unable to return steps") - result.success(null) + private fun getTotalStepsInInterval(call: MethodCall, result: Result) { + val start = call.argument("startTime")!! + val end = call.argument("endTime")!! + + if (useHealthConnectIfAvailable && healthConnectAvailable) { + getStepsHealthConnect(start, end, result) + return + } + + val context = context ?: return + + val stepsDataType = keyToHealthDataType(STEPS) + val aggregatedDataType = keyToHealthDataType(AGGREGATE_STEP_COUNT) + + val fitnessOptions = + FitnessOptions.builder() + .addDataType(stepsDataType) + .addDataType(aggregatedDataType) + .build() + val gsa = GoogleSignIn.getAccountForExtension(context, fitnessOptions) + + val ds = + DataSource.Builder() + .setAppPackageName("com.google.android.gms") + .setDataType(stepsDataType) + .setType(DataSource.TYPE_DERIVED) + .setStreamName("estimated_steps") + .build() + + val duration = (end - start).toInt() + + val request = + DataReadRequest.Builder() + .aggregate(ds) + .bucketByTime(duration, TimeUnit.MILLISECONDS) + .setTimeRange(start, end, TimeUnit.MILLISECONDS) + .build() + + Fitness.getHistoryClient(context, gsa) + .readData(request) + .addOnFailureListener( + errHandler( + result, + "There was an error getting the total steps in the interval!", + ), + ) + .addOnSuccessListener( + threadPoolExecutor!!, + getStepsInRange( + start, + end, + aggregatedDataType, + result + ), + ) } - } - - private fun getStepsInRange( - start: Long, - end: Long, - aggregatedDataType: DataType, - result: Result, - ) = - OnSuccessListener { response: DataReadResponse -> - val map = HashMap() // need to return to Dart so can't use sparse array - for (bucket in response.buckets) { - val dp = bucket.dataSets.firstOrNull()?.dataPoints?.firstOrNull() - if (dp != null) { - val count = dp.getValue(aggregatedDataType.fields[0]) - - val startTime = dp.getStartTime(TimeUnit.MILLISECONDS) - val startDate = Date(startTime) - val endDate = Date(dp.getEndTime(TimeUnit.MILLISECONDS)) - Log.i( - "FLUTTER_HEALTH::SUCCESS", - "returning $count steps for $startDate - $endDate", - ) - map[startTime] = count.asInt() - } else { - val startDay = Date(start) - val endDay = Date(end) - Log.i("FLUTTER_HEALTH::ERROR", "no steps for $startDay - $endDay") + + private fun getStepsHealthConnect(start: Long, end: Long, result: Result) = + scope.launch { + try { + val startInstant = Instant.ofEpochMilli(start) + val endInstant = Instant.ofEpochMilli(end) + val response = + healthConnectClient.aggregate( + AggregateRequest( + metrics = + setOf( + StepsRecord.COUNT_TOTAL + ), + timeRangeFilter = + TimeRangeFilter.between( + startInstant, + endInstant + ), + ), + ) + // The result may be null if no data is available in the + // time range. + val stepsInInterval = + response[StepsRecord.COUNT_TOTAL] ?: 0L + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "returning $stepsInInterval steps" + ) + result.success(stepsInInterval) + } catch (e: Exception) { + Log.i("FLUTTER_HEALTH::ERROR", "unable to return steps") + result.success(null) + } + } + + private fun getStepsInRange( + start: Long, + end: Long, + aggregatedDataType: DataType, + result: Result, + ) = OnSuccessListener { response: DataReadResponse -> + val map = HashMap() // need to return to Dart so can't use sparse array + for (bucket in response.buckets) { + val dp = bucket.dataSets.firstOrNull()?.dataPoints?.firstOrNull() + if (dp != null) { + val count = dp.getValue(aggregatedDataType.fields[0]) + + val startTime = dp.getStartTime(TimeUnit.MILLISECONDS) + val startDate = Date(startTime) + val endDate = Date(dp.getEndTime(TimeUnit.MILLISECONDS)) + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "returning $count steps for $startDate - $endDate", + ) + map[startTime] = count.asInt() + } else { + val startDay = Date(start) + val endDay = Date(end) + Log.i("FLUTTER_HEALTH::ERROR", "no steps for $startDay - $endDay") + } } - } - assert(map.size <= 1) { "getTotalStepsInInterval should return only one interval. Found: ${map.size}" } - Handler(context!!.mainLooper).run { - result.success(map.values.firstOrNull()) - } + assert(map.size <= 1) { + "getTotalStepsInInterval should return only one interval. Found: ${map.size}" + } + Handler(context!!.mainLooper).run { result.success(map.values.firstOrNull()) } } - private fun getActivityType(type: String): String { - return workoutTypeMap[type] ?: FitnessActivities.UNKNOWN - } - - /** - * Handle calls from the MethodChannel - */ - override fun onMethodCall(call: MethodCall, result: Result) { - when (call.method) { - "useHealthConnectIfAvailable" -> useHealthConnectIfAvailable(call, result) - "hasPermissions" -> hasPermissions(call, result) - "requestAuthorization" -> requestAuthorization(call, result) - "revokePermissions" -> revokePermissions(call, result) - "getData" -> getData(call, result) - "writeData" -> writeData(call, result) - "delete" -> delete(call, result) - "getTotalStepsInInterval" -> getTotalStepsInInterval(call, result) - "writeWorkoutData" -> writeWorkoutData(call, result) - "writeBloodPressure" -> writeBloodPressure(call, result) - "writeBloodOxygen" -> writeBloodOxygen(call, result) - "writeMeal" -> writeMeal(call, result) - else -> result.notImplemented() + /// Disconnect Google fit + private fun disconnect(call: MethodCall, result: Result) { + if (activity == null) { + result.success(false) + return + } + val context = activity!!.applicationContext + + val fitnessOptions = callToHealthTypes(call) + val googleAccount = GoogleSignIn.getAccountForExtension(context, fitnessOptions) + Fitness.getConfigClient(context, googleAccount).disableFit().continueWith { + val signinOption = + GoogleSignInOptions.Builder( + GoogleSignInOptions + .DEFAULT_SIGN_IN + ) + .requestId() + .requestEmail() + .build() + val googleSignInClient = GoogleSignIn.getClient(context, signinOption) + googleSignInClient.signOut() + result.success(true) + } } - } - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - if (channel == null) { - return + private fun getActivityType(type: String): String { + return workoutTypeMap[type] ?: FitnessActivities.UNKNOWN + } + + /** Handle calls from the MethodChannel */ + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "installHealthConnect" -> installHealthConnect(call, result) + "useHealthConnectIfAvailable" -> useHealthConnectIfAvailable(call, result) + "getHealthConnectSdkStatus" -> getHealthConnectSdkStatus(call, result) + "hasPermissions" -> hasPermissions(call, result) + "requestAuthorization" -> requestAuthorization(call, result) + "revokePermissions" -> revokePermissions(call, result) + "getData" -> getData(call, result) + "getIntervalData" -> getIntervalData(call, result) + "writeData" -> writeData(call, result) + "delete" -> delete(call, result) + "getAggregateData" -> getAggregateData(call, result) + "getTotalStepsInInterval" -> getTotalStepsInInterval(call, result) + "writeWorkoutData" -> writeWorkoutData(call, result) + "writeBloodPressure" -> writeBloodPressure(call, result) + "writeBloodOxygen" -> writeBloodOxygen(call, result) + "writeMeal" -> writeMeal(call, result) + "disconnect" -> disconnect(call, result) + else -> result.notImplemented() + } } - binding.addActivityResultListener(this) - activity = binding.activity + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + if (channel == null) { + return + } + binding.addActivityResultListener(this) + activity = binding.activity - if ( healthConnectAvailable) { - val requestPermissionActivityContract = PermissionController.createRequestPermissionResultContract() + val requestPermissionActivityContract = + PermissionController.createRequestPermissionResultContract() - healthConnectRequestPermissionsLauncher =(activity as ComponentActivity).registerForActivityResult(requestPermissionActivityContract) { granted -> - onHealthConnectPermissionCallback(granted); - } + healthConnectRequestPermissionsLauncher = + (activity as ComponentActivity).registerForActivityResult( + requestPermissionActivityContract + ) { granted -> onHealthConnectPermissionCallback(granted) } } - } + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } - override fun onDetachedFromActivityForConfigChanges() { - onDetachedFromActivity() - } + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - onAttachedToActivity(binding) - } + override fun onDetachedFromActivity() { + if (channel == null) { + return + } + activity = null + healthConnectRequestPermissionsLauncher = null + } - override fun onDetachedFromActivity() { - if (channel == null) { - return + /** HEALTH CONNECT BELOW */ + var healthConnectAvailable = false + var healthConnectStatus = HealthConnectClient.SDK_UNAVAILABLE + + fun checkAvailability() { + healthConnectStatus = HealthConnectClient.getSdkStatus(context!!) + healthConnectAvailable = healthConnectStatus == HealthConnectClient.SDK_AVAILABLE } - activity = null - healthConnectRequestPermissionsLauncher = null; - } - - /** - * HEALTH CONNECT BELOW - */ - var healthConnectAvailable = false - var healthConnectStatus = HealthConnectClient.SDK_UNAVAILABLE - - fun checkAvailability() { - healthConnectStatus = HealthConnectClient.getSdkStatus(context!!) - healthConnectAvailable = healthConnectStatus == HealthConnectClient.SDK_AVAILABLE - } - - fun useHealthConnectIfAvailable(call: MethodCall, result: Result) { - useHealthConnectIfAvailable = true - result.success(null) - } - - private fun hasPermissionsHC(call: MethodCall, result: Result) { - val args = call.arguments as HashMap<*, *> - val types = (args["types"] as? ArrayList<*>)?.filterIsInstance()!! - val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance()!! - - var permList = mutableListOf() - for ((i, typeKey) in types.withIndex()) { - if(!MapToHCType.containsKey(typeKey)) { - Log.w("FLUTTER_HEALTH::ERROR", "Datatype " + typeKey + " not found in HC") - result.success(false) - return - } - val access = permissions[i] - val dataType = MapToHCType[typeKey]!! - if (access == 0) { - permList.add( - HealthPermission.getReadPermission(dataType), - ) - } else { - permList.addAll( - listOf( - HealthPermission.getReadPermission(dataType), - HealthPermission.getWritePermission(dataType), - ), + + private fun installHealthConnect(call: MethodCall, result: Result) { + val uriString = + "market://details?id=com.google.android.apps.healthdata&url=healthconnect%3A%2F%2Fonboarding" + context!!.startActivity( + Intent(Intent.ACTION_VIEW).apply { + setPackage("com.android.vending") + data = Uri.parse(uriString) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("overlay", true) + putExtra("callerId", context!!.packageName) + } ) - } - // Workout also needs distance and total energy burned too - if (typeKey == WORKOUT) { - if (access == 0) { - permList.addAll( - listOf( - HealthPermission.getReadPermission(DistanceRecord::class), - HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), - ), - ) - } else { - permList.addAll( - listOf( - HealthPermission.getReadPermission(DistanceRecord::class), - HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), - HealthPermission.getWritePermission(DistanceRecord::class), - HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class), - ), - ) - } - } - } - scope.launch { - result.success( - healthConnectClient.permissionController.getGrantedPermissions() - .containsAll(permList), - ) + result.success(null) } - } - - private fun requestAuthorizationHC(call: MethodCall, result: Result) { - val args = call.arguments as HashMap<*, *> - val types = (args["types"] as? ArrayList<*>)?.filterIsInstance()!! - val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance()!! - - var permList = mutableListOf() - for ((i, typeKey) in types.withIndex()) { - if(!MapToHCType.containsKey(typeKey)) { - Log.w("FLUTTER_HEALTH::ERROR", "Datatype " + typeKey + " not found in HC") - result.success(false) - return - } - val access = permissions[i]!! - val dataType = MapToHCType[typeKey]!! - if (access == 0) { - permList.add( - HealthPermission.getReadPermission(dataType), - ) - } else { - permList.addAll( - listOf( - HealthPermission.getReadPermission(dataType), - HealthPermission.getWritePermission(dataType), - ), - ) - } - // Workout also needs distance and total energy burned too - if (typeKey == WORKOUT) { - if (access == 0) { - permList.addAll( - listOf( - HealthPermission.getReadPermission(DistanceRecord::class), - HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), - ), - ) - } else { - permList.addAll( - listOf( - HealthPermission.getReadPermission(DistanceRecord::class), - HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), - HealthPermission.getWritePermission(DistanceRecord::class), - HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class), - ), - ) - } - } + + fun useHealthConnectIfAvailable(call: MethodCall, result: Result) { + useHealthConnectIfAvailable = true + result.success(null) } - - if(healthConnectRequestPermissionsLauncher == null) { - result.success(false) - Log.i("FLUTTER_HEALTH", "Permission launcher not found") - return; + + private fun getHealthConnectSdkStatus(call: MethodCall, result: Result) { + checkAvailability() + if (healthConnectAvailable) { + healthConnectClient = + HealthConnectClient.getOrCreate( + context!! + ) + } + result.success(healthConnectStatus) } + private fun hasPermissionsHC(call: MethodCall, result: Result) { + val args = call.arguments as HashMap<*, *> + val types = (args["types"] as? ArrayList<*>)?.filterIsInstance()!! + val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance()!! - healthConnectRequestPermissionsLauncher!!.launch(permList.toSet()); - } + var permList = mutableListOf() + for ((i, typeKey) in types.withIndex()) { + if (!MapToHCType.containsKey(typeKey)) { + Log.w( + "FLUTTER_HEALTH::ERROR", + "Datatype " + typeKey + " not found in HC" + ) + result.success(false) + return + } + val access = permissions[i] + val dataType = MapToHCType[typeKey]!! + if (access == 0) { + permList.add( + HealthPermission.getReadPermission(dataType), + ) + } else { + permList.addAll( + listOf( + HealthPermission.getReadPermission( + dataType + ), + HealthPermission.getWritePermission( + dataType + ), + ), + ) + } + // Workout also needs distance and total energy burned too + if (typeKey == WORKOUT) { + if (access == 0) { + permList.addAll( + listOf( + HealthPermission.getReadPermission( + DistanceRecord::class + ), + HealthPermission.getReadPermission( + TotalCaloriesBurnedRecord::class + ), + ), + ) + } else { + permList.addAll( + listOf( + HealthPermission.getReadPermission( + DistanceRecord::class + ), + HealthPermission.getReadPermission( + TotalCaloriesBurnedRecord::class + ), + HealthPermission.getWritePermission( + DistanceRecord::class + ), + HealthPermission.getWritePermission( + TotalCaloriesBurnedRecord::class + ), + ), + ) + } + } + } + scope.launch { + result.success( + healthConnectClient + .permissionController + .getGrantedPermissions() + .containsAll(permList), + ) + } + } - fun getHCData(call: MethodCall, result: Result) { - val dataType = call.argument("dataTypeKey")!! - val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) - val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) - val healthConnectData = mutableListOf>() - scope.launch { - MapToHCType[dataType]?.let { classType -> - val records = mutableListOf() + private fun requestAuthorizationHC(call: MethodCall, result: Result) { + val args = call.arguments as HashMap<*, *> + val types = (args["types"] as? ArrayList<*>)?.filterIsInstance()!! + val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance()!! - // Set up the initial request to read health records with specified parameters - var request = ReadRecordsRequest( - recordType = classType, - // Define the maximum amount of data that HealthConnect can return in a single request - timeRangeFilter = TimeRangeFilter.between(startTime, endTime), - ) - - var response = healthConnectClient.readRecords(request) - var pageToken = response.pageToken - - // Add the records from the initial response to the records list - records.addAll(response.records) - - // Continue making requests and fetching records while there is a page token - while (!pageToken.isNullOrEmpty()) { - request = ReadRecordsRequest( - recordType = classType, - timeRangeFilter = TimeRangeFilter.between(startTime, endTime), - pageToken = pageToken - ) - response = healthConnectClient.readRecords(request) - - pageToken = response.pageToken - records.addAll(response.records) - } - - // Workout needs distance and total calories burned too - if (dataType == WORKOUT) { - for (rec in records) { - val record = rec as ExerciseSessionRecord - val distanceRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = DistanceRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime, - ), - ), - ) - var totalDistance = 0.0 - for (distanceRec in distanceRequest.records) { - totalDistance += distanceRec.distance.inMeters + var permList = mutableListOf() + for ((i, typeKey) in types.withIndex()) { + if (!MapToHCType.containsKey(typeKey)) { + Log.w( + "FLUTTER_HEALTH::ERROR", + "Datatype " + typeKey + " not found in HC" + ) + result.success(false) + return } - - val energyBurnedRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = TotalCaloriesBurnedRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime, - ), - ), - ) - var totalEnergyBurned = 0.0 - for (energyBurnedRec in energyBurnedRequest.records) { - totalEnergyBurned += energyBurnedRec.energy.inKilocalories + val access = permissions[i]!! + val dataType = MapToHCType[typeKey]!! + if (access == 0) { + permList.add( + HealthPermission.getReadPermission(dataType), + ) + } else { + permList.addAll( + listOf( + HealthPermission.getReadPermission( + dataType + ), + HealthPermission.getWritePermission( + dataType + ), + ), + ) } - - // val metadata = (rec as Record).metadata - // Add final datapoint - healthConnectData.add( - // mapOf( - mapOf( - "workoutActivityType" to ( - workoutTypeMapHealthConnect.filterValues { it == record.exerciseType }.keys.firstOrNull() - ?: "OTHER" - ), - "totalDistance" to if (totalDistance == 0.0) null else totalDistance, - "totalDistanceUnit" to "METER", - "totalEnergyBurned" to if (totalEnergyBurned == 0.0) null else totalEnergyBurned, - "totalEnergyBurnedUnit" to "KILOCALORIE", - "unit" to "MINUTES", - "date_from" to rec.startTime.toEpochMilli(), - "date_to" to rec.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to record.metadata.dataOrigin.packageName, - ), - ) - } - // Filter sleep stages for requested stage - } - else if (classType == SleepSessionRecord::class) { - for (rec in response.records) { - if (rec is SleepSessionRecord) { - if (dataType == SLEEP_SESSION) { - healthConnectData.addAll(convertRecord(rec, dataType)) - } - else { - for (recStage in rec.stages) { - if (dataType == MapSleepStageToType[recStage.stage]) { - healthConnectData.addAll( - convertRecordStage( - recStage, dataType, - rec.metadata.dataOrigin.packageName - ) + // Workout also needs distance and total energy burned too + if (typeKey == WORKOUT) { + if (access == 0) { + permList.addAll( + listOf( + HealthPermission.getReadPermission( + DistanceRecord::class + ), + HealthPermission.getReadPermission( + TotalCaloriesBurnedRecord::class + ), + ), + ) + } else { + permList.addAll( + listOf( + HealthPermission.getReadPermission( + DistanceRecord::class + ), + HealthPermission.getReadPermission( + TotalCaloriesBurnedRecord::class + ), + HealthPermission.getWritePermission( + DistanceRecord::class + ), + HealthPermission.getWritePermission( + TotalCaloriesBurnedRecord::class + ), + ), ) - } } - } } - } - } else { - for (rec in records) { - healthConnectData.addAll(convertRecord(rec, dataType)) - } } - } - Handler(context!!.mainLooper).run { result.success(healthConnectData) } + if (healthConnectRequestPermissionsLauncher == null) { + result.success(false) + Log.i("FLUTTER_HEALTH", "Permission launcher not found") + return + } + + healthConnectRequestPermissionsLauncher!!.launch(permList.toSet()) + } + + fun getHCData(call: MethodCall, result: Result) { + val dataType = call.argument("dataTypeKey")!! + val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) + val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) + val healthConnectData = mutableListOf>() + scope.launch { + MapToHCType[dataType]?.let { classType -> + val records = mutableListOf() + + // Set up the initial request to read health records with specified + // parameters + var request = + ReadRecordsRequest( + recordType = classType, + // Define the maximum amount of data + // that HealthConnect can return + // in a single request + timeRangeFilter = + TimeRangeFilter.between( + startTime, + endTime + ), + ) + + var response = healthConnectClient.readRecords(request) + var pageToken = response.pageToken + + // Add the records from the initial response to the records list + records.addAll(response.records) + + // Continue making requests and fetching records while there is a + // page token + while (!pageToken.isNullOrEmpty()) { + request = + ReadRecordsRequest( + recordType = classType, + timeRangeFilter = + TimeRangeFilter.between( + startTime, + endTime + ), + pageToken = pageToken + ) + response = healthConnectClient.readRecords(request) + + pageToken = response.pageToken + records.addAll(response.records) + } + + // Workout needs distance and total calories burned too + if (dataType == WORKOUT) { + for (rec in records) { + val record = rec as ExerciseSessionRecord + val distanceRequest = + healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = + DistanceRecord::class, + timeRangeFilter = + TimeRangeFilter.between( + record.startTime, + record.endTime, + ), + ), + ) + var totalDistance = 0.0 + for (distanceRec in distanceRequest.records) { + totalDistance += + distanceRec.distance + .inMeters + } + + val energyBurnedRequest = + healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = + TotalCaloriesBurnedRecord::class, + timeRangeFilter = + TimeRangeFilter.between( + record.startTime, + record.endTime, + ), + ), + ) + var totalEnergyBurned = 0.0 + for (energyBurnedRec in + energyBurnedRequest.records) { + totalEnergyBurned += + energyBurnedRec.energy + .inKilocalories + } + + val stepRequest = + healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = + StepsRecord::class, + timeRangeFilter = + TimeRangeFilter.between( + record.startTime, + record.endTime + ), + ), + ) + var totalSteps = 0.0 + for (stepRec in stepRequest.records) { + totalSteps += stepRec.count + } + + // val metadata = (rec as Record).metadata + // Add final datapoint + healthConnectData.add( + // mapOf( + mapOf( + "workoutActivityType" to + (workoutTypeMapHealthConnect + .filterValues { + it == + record.exerciseType + } + .keys + .firstOrNull() + ?: "OTHER"), + "totalDistance" to + if (totalDistance == + 0.0 + ) + null + else + totalDistance, + "totalDistanceUnit" to + "METER", + "totalEnergyBurned" to + if (totalEnergyBurned == + 0.0 + ) + null + else + totalEnergyBurned, + "totalEnergyBurnedUnit" to + "KILOCALORIE", + "totalSteps" to + if (totalSteps == + 0.0 + ) + null + else + totalSteps, + "totalStepsUnit" to + "COUNT", + "unit" to "MINUTES", + "date_from" to + rec.startTime + .toEpochMilli(), + "date_to" to + rec.endTime.toEpochMilli(), + "source_id" to "", + "source_name" to + record.metadata + .dataOrigin + .packageName, + ), + ) + } + // Filter sleep stages for requested stage + } else if (classType == SleepSessionRecord::class) { + for (rec in response.records) { + if (rec is SleepSessionRecord) { + if (dataType == SLEEP_SESSION) { + healthConnectData.addAll( + convertRecord( + rec, + dataType + ) + ) + } else { + for (recStage in rec.stages) { + if (dataType == + MapSleepStageToType[ + recStage.stage] + ) { + healthConnectData + .addAll( + convertRecordStage( + recStage, + dataType, + rec.metadata.dataOrigin + .packageName + ) + ) + } + } + } + } + } + } else { + for (rec in records) { + healthConnectData.addAll( + convertRecord(rec, dataType) + ) + } + } + } + Handler(context!!.mainLooper).run { result.success(healthConnectData) } + } } - } - - fun convertRecordStage(stage: SleepSessionRecord.Stage, dataType: String, sourceName: String): - List> { - return listOf( - mapOf( - "stage" to stage.stage, - "value" to ChronoUnit.MINUTES.between(stage.startTime, stage.endTime), - "date_from" to stage.startTime.toEpochMilli(), - "date_to" to stage.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to sourceName, - ), - ); - } - - // TODO: Find alternative to SOURCE_ID or make it nullable? - fun convertRecord(record: Any, dataType: String): List> { - val metadata = (record as Record).metadata - when (record) { - is WeightRecord -> return listOf( - mapOf( - "value" to record.weight.inKilograms, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is HeightRecord -> return listOf( - mapOf( - "value" to record.height.inMeters, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is BodyFatRecord -> return listOf( - mapOf( - "value" to record.percentage.value, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is StepsRecord -> return listOf( - mapOf( - "value" to record.count, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is ActiveCaloriesBurnedRecord -> return listOf( - mapOf( - "value" to record.energy.inKilocalories, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is HeartRateRecord -> return record.samples.map { - mapOf( - "value" to it.beatsPerMinute, - "date_from" to it.time.toEpochMilli(), - "date_to" to it.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - } - - is BodyTemperatureRecord -> return listOf( - mapOf( - "value" to record.temperature.inCelsius, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is BloodPressureRecord -> return listOf( - mapOf( - "value" to if (dataType == BLOOD_PRESSURE_DIASTOLIC) record.diastolic.inMillimetersOfMercury else record.systolic.inMillimetersOfMercury, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is OxygenSaturationRecord -> return listOf( - mapOf( - "value" to record.percentage.value, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is BloodGlucoseRecord -> return listOf( - mapOf( - "value" to record.level.inMilligramsPerDeciliter, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is DistanceRecord -> return listOf( - mapOf( - "value" to record.distance.inMeters, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is HydrationRecord -> return listOf( - mapOf( - "value" to record.volume.inLiters, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is SleepSessionRecord -> return listOf( - mapOf( - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "value" to ChronoUnit.MINUTES.between(record.startTime, record.endTime), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - is RestingHeartRateRecord -> return listOf( - mapOf( - "value" to record.beatsPerMinute, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - ) - - is BasalMetabolicRateRecord -> return listOf( - mapOf( - "value" to record.basalMetabolicRate.inKilocaloriesPerDay, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - ) - - is FloorsClimbedRecord -> return listOf( - mapOf( - "value" to record.floors, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - ) - - is RespiratoryRateRecord -> return listOf( - mapOf( - "value" to record.rate, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - ) - - is NutritionRecord -> return listOf( - mapOf( - "calories" to record.energy!!.inKilocalories, - "protein" to record.protein!!.inGrams, - "carbs" to record.totalCarbohydrate!!.inGrams, - "fat" to record.totalFat!!.inGrams, - "name" to record.name!!, - "mealType" to (MapTypeToMealTypeHC[record.mealType] ?: MEAL_TYPE_UNKNOWN), - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, + + fun convertRecordStage( + stage: SleepSessionRecord.Stage, + dataType: String, + sourceName: String + ): List> { + return listOf( + mapOf( + "stage" to stage.stage, + "value" to + ChronoUnit.MINUTES.between( + stage.startTime, + stage.endTime + ), + "date_from" to stage.startTime.toEpochMilli(), + "date_to" to stage.endTime.toEpochMilli(), + "source_id" to "", + "source_name" to sourceName, + ), ) - ) - // is ExerciseSessionRecord -> return listOf(mapOf("value" to , - // "date_from" to , - // "date_to" to , - // "source_id" to "", - // "source_name" to metadata.dataOrigin.packageName)) - else -> throw IllegalArgumentException("Health data type not supported") // TODO: Exception or error? - } - } - - //TODO rewrite sleep to fit new update better --> compare with Apple and see if we should not adopt a single type with attached stages approach - fun writeHCData(call: MethodCall, result: Result) { - val type = call.argument("dataTypeKey")!! - val startTime = call.argument("startTime")!! - val endTime = call.argument("endTime")!! - val value = call.argument("value")!! - val record = when (type) { - BODY_FAT_PERCENTAGE -> BodyFatRecord( - time = Instant.ofEpochMilli(startTime), - percentage = Percentage(value), - zoneOffset = null, - ) - - HEIGHT -> HeightRecord( - time = Instant.ofEpochMilli(startTime), - height = Length.meters(value), - zoneOffset = null, - ) - - WEIGHT -> WeightRecord( - time = Instant.ofEpochMilli(startTime), - weight = Mass.kilograms(value), - zoneOffset = null, - ) - - STEPS -> StepsRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - count = value.toLong(), - startZoneOffset = null, - endZoneOffset = null, - ) - - ACTIVE_ENERGY_BURNED -> ActiveCaloriesBurnedRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - energy = Energy.kilocalories(value), - startZoneOffset = null, - endZoneOffset = null, - ) - - HEART_RATE -> HeartRateRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - samples = listOf( - HeartRateRecord.Sample( - time = Instant.ofEpochMilli(startTime), - beatsPerMinute = value.toLong(), - ), - ), - startZoneOffset = null, - endZoneOffset = null, - ) - - BODY_TEMPERATURE -> BodyTemperatureRecord( - time = Instant.ofEpochMilli(startTime), - temperature = Temperature.celsius(value), - zoneOffset = null, - ) - - BLOOD_OXYGEN -> OxygenSaturationRecord( - time = Instant.ofEpochMilli(startTime), - percentage = Percentage(value), - zoneOffset = null, - ) - - BLOOD_GLUCOSE -> BloodGlucoseRecord( - time = Instant.ofEpochMilli(startTime), - level = BloodGlucose.milligramsPerDeciliter(value), - zoneOffset = null, - ) - - DISTANCE_DELTA -> DistanceRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - distance = Length.meters(value), - startZoneOffset = null, - endZoneOffset = null, - ) - - WATER -> HydrationRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - volume = Volume.liters(value), - startZoneOffset = null, - endZoneOffset = null, - ) - SLEEP_ASLEEP -> SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stages = listOf(SleepSessionRecord.Stage(Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime), SleepSessionRecord.STAGE_TYPE_SLEEPING)), - ) - SLEEP_LIGHT -> SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stages = listOf(SleepSessionRecord.Stage(Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime), SleepSessionRecord.STAGE_TYPE_LIGHT)), - ) - SLEEP_DEEP -> SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stages = listOf(SleepSessionRecord.Stage(Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime), SleepSessionRecord.STAGE_TYPE_DEEP)), - ) - SLEEP_REM -> SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stages = listOf(SleepSessionRecord.Stage(Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime), SleepSessionRecord.STAGE_TYPE_REM)), - ) - SLEEP_OUT_OF_BED -> SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stages = listOf(SleepSessionRecord.Stage(Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime), SleepSessionRecord.STAGE_TYPE_OUT_OF_BED)), - ) - SLEEP_AWAKE -> SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stages = listOf(SleepSessionRecord.Stage(Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime), SleepSessionRecord.STAGE_TYPE_AWAKE)), - ) - SLEEP_SESSION -> SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - ) - - RESTING_HEART_RATE -> RestingHeartRateRecord( - time = Instant.ofEpochMilli(startTime), - beatsPerMinute = value.toLong(), - zoneOffset = null, - ) - - BASAL_ENERGY_BURNED -> BasalMetabolicRateRecord( - time = Instant.ofEpochMilli(startTime), - basalMetabolicRate = Power.kilocaloriesPerDay(value), - zoneOffset = null, - ) - - FLIGHTS_CLIMBED -> FloorsClimbedRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - floors = value, - startZoneOffset = null, - endZoneOffset = null, - ) - - RESPIRATORY_RATE -> RespiratoryRateRecord( - time = Instant.ofEpochMilli(startTime), - rate = value, - zoneOffset = null, - ) - // AGGREGATE_STEP_COUNT -> StepsRecord() - BLOOD_PRESSURE_SYSTOLIC -> throw IllegalArgumentException("You must use the [writeBloodPressure] API ") - BLOOD_PRESSURE_DIASTOLIC -> throw IllegalArgumentException("You must use the [writeBloodPressure] API ") - WORKOUT -> throw IllegalArgumentException("You must use the [writeWorkoutData] API ") - NUTRITION -> throw IllegalArgumentException("You must use the [writeMeal] API ") - else -> throw IllegalArgumentException("The type $type was not supported by the Health plugin or you must use another API ") } - scope.launch { - try { - healthConnectClient.insertRecords(listOf(record)) - result.success(true) - } catch (e: Exception) { - result.success(false) - } + + fun getAggregateHCData(call: MethodCall, result: Result) { + val dataType = call.argument("dataTypeKey")!! + val interval = call.argument("interval")!! + val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) + val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) + val healthConnectData = mutableListOf>() + scope.launch { + MapToHCAggregateMetric[dataType]?.let { metricClassType -> + val request = + AggregateGroupByDurationRequest( + metrics = setOf(metricClassType), + timeRangeFilter = + TimeRangeFilter.between( + startTime, + endTime + ), + timeRangeSlicer = + Duration.ofSeconds( + interval + ) + ) + val response = healthConnectClient.aggregateGroupByDuration(request) + + for (durationResult in response) { + // The result may be null if no data is available in the + // time range + var totalValue = durationResult.result[metricClassType] + if (totalValue is Length) { + totalValue = totalValue.inMeters + } else if (totalValue is Energy) { + totalValue = totalValue.inKilocalories + } + + val packageNames = + durationResult.result.dataOrigins + .joinToString { origin -> + "${origin.packageName}" + } + + val data = + mapOf( + "value" to + (totalValue + ?: 0), + "date_from" to + durationResult.startTime + .toEpochMilli(), + "date_to" to + durationResult.endTime + .toEpochMilli(), + "source_name" to + packageNames, + "source_id" to "", + "is_manual_entry" to + packageNames.contains( + "user_input" + ) + ) + healthConnectData.add(data) + } + } + Handler(context!!.mainLooper).run { result.success(healthConnectData) } + } } - } - - fun writeWorkoutHCData(call: MethodCall, result: Result) { - val type = call.argument("activityType")!! - val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) - val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) - val totalEnergyBurned = call.argument("totalEnergyBurned") - val totalDistance = call.argument("totalDistance") - if(workoutTypeMapHealthConnect.containsKey(type) == false) { - result.success(false) - Log.w("FLUTTER_HEALTH::ERROR", "[Health Connect] Workout type not supported") - return + + // TODO: Find alternative to SOURCE_ID or make it nullable? + fun convertRecord(record: Any, dataType: String): List> { + val metadata = (record as Record).metadata + when (record) { + is WeightRecord -> + return listOf( + mapOf( + "value" to + record.weight + .inKilograms, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is HeightRecord -> + return listOf( + mapOf( + "value" to + record.height + .inMeters, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is BodyFatRecord -> + return listOf( + mapOf( + "value" to + record.percentage + .value, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is StepsRecord -> + return listOf( + mapOf( + "value" to record.count, + "date_from" to + record.startTime + .toEpochMilli(), + "date_to" to + record.endTime + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is ActiveCaloriesBurnedRecord -> + return listOf( + mapOf( + "value" to + record.energy + .inKilocalories, + "date_from" to + record.startTime + .toEpochMilli(), + "date_to" to + record.endTime + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is HeartRateRecord -> + return record.samples.map { + mapOf( + "value" to it.beatsPerMinute, + "date_from" to + it.time.toEpochMilli(), + "date_to" to it.time.toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ) + } + is BodyTemperatureRecord -> + return listOf( + mapOf( + "value" to + record.temperature + .inCelsius, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is BodyWaterMassRecord -> + return listOf( + mapOf( + "value" to + record.mass + .inKilograms, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is BloodPressureRecord -> + return listOf( + mapOf( + "value" to + if (dataType == + BLOOD_PRESSURE_DIASTOLIC + ) + record.diastolic + .inMillimetersOfMercury + else + record.systolic + .inMillimetersOfMercury, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is OxygenSaturationRecord -> + return listOf( + mapOf( + "value" to + record.percentage + .value, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is BloodGlucoseRecord -> + return listOf( + mapOf( + "value" to + record.level + .inMilligramsPerDeciliter, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is DistanceRecord -> + return listOf( + mapOf( + "value" to + record.distance + .inMeters, + "date_from" to + record.startTime + .toEpochMilli(), + "date_to" to + record.endTime + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is HydrationRecord -> + return listOf( + mapOf( + "value" to + record.volume + .inLiters, + "date_from" to + record.startTime + .toEpochMilli(), + "date_to" to + record.endTime + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is TotalCaloriesBurnedRecord -> + return listOf( + mapOf( + "value" to + record.energy + .inKilocalories, + "date_from" to + record.startTime + .toEpochMilli(), + "date_to" to + record.endTime + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is BasalMetabolicRateRecord -> + return listOf( + mapOf( + "value" to + record.basalMetabolicRate + .inKilocaloriesPerDay, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is SleepSessionRecord -> + return listOf( + mapOf( + "date_from" to + record.startTime + .toEpochMilli(), + "date_to" to + record.endTime + .toEpochMilli(), + "value" to + ChronoUnit.MINUTES + .between( + record.startTime, + record.endTime + ), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ), + ) + is RestingHeartRateRecord -> + return listOf( + mapOf( + "value" to + record.beatsPerMinute, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ) + ) + is BasalMetabolicRateRecord -> + return listOf( + mapOf( + "value" to + record.basalMetabolicRate + .inKilocaloriesPerDay, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ) + ) + is FloorsClimbedRecord -> + return listOf( + mapOf( + "value" to record.floors, + "date_from" to + record.startTime + .toEpochMilli(), + "date_to" to + record.endTime + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ) + ) + is RespiratoryRateRecord -> + return listOf( + mapOf( + "value" to record.rate, + "date_from" to + record.time + .toEpochMilli(), + "date_to" to + record.time + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ) + ) + is NutritionRecord -> + return listOf( + mapOf( + "calories" to + record.energy!!.inKilocalories, + "protein" to + record.protein!!.inGrams, + "carbs" to + record.totalCarbohydrate!! + .inGrams, + "fat" to + record.totalFat!! + .inGrams, + "name" to record.name!!, + "mealType" to + (MapTypeToMealTypeHC[ + record.mealType] + ?: MEAL_TYPE_UNKNOWN), + "date_from" to + record.startTime + .toEpochMilli(), + "date_to" to + record.endTime + .toEpochMilli(), + "source_id" to "", + "source_name" to + metadata.dataOrigin + .packageName, + ) + ) + // is ExerciseSessionRecord -> return listOf(mapOf("value" to , + // "date_from" to , + // "date_to" to , + // "source_id" to "", + // "source_name" to + // metadata.dataOrigin.packageName)) + else -> + throw IllegalArgumentException( + "Health data type not supported" + ) // TODO: Exception or error? + } } - val workoutType = workoutTypeMapHealthConnect[type]!! - - scope.launch { - try { - val list = mutableListOf() - list.add( - ExerciseSessionRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - exerciseType = workoutType, - title = type, - ), - ) - if (totalDistance != null) { - list.add( - DistanceRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - distance = Length.meters(totalDistance.toDouble()), - ), - ) + + // TODO rewrite sleep to fit new update better --> compare with Apple and see if we should + // not + // adopt a single type with attached stages approach + fun writeHCData(call: MethodCall, result: Result) { + val type = call.argument("dataTypeKey")!! + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! + val value = call.argument("value")!! + val record = + when (type) { + BODY_FAT_PERCENTAGE -> + BodyFatRecord( + time = + Instant.ofEpochMilli( + startTime + ), + percentage = + Percentage( + value + ), + zoneOffset = null, + ) + HEIGHT -> + HeightRecord( + time = + Instant.ofEpochMilli( + startTime + ), + height = + Length.meters( + value + ), + zoneOffset = null, + ) + WEIGHT -> + WeightRecord( + time = + Instant.ofEpochMilli( + startTime + ), + weight = + Mass.kilograms( + value + ), + zoneOffset = null, + ) + STEPS -> + StepsRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + count = value.toLong(), + startZoneOffset = null, + endZoneOffset = null, + ) + ACTIVE_ENERGY_BURNED -> + ActiveCaloriesBurnedRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + energy = + Energy.kilocalories( + value + ), + startZoneOffset = null, + endZoneOffset = null, + ) + HEART_RATE -> + HeartRateRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + samples = + listOf< + HeartRateRecord.Sample>( + HeartRateRecord.Sample( + time = + Instant.ofEpochMilli( + startTime + ), + beatsPerMinute = + value.toLong(), + ), + ), + startZoneOffset = null, + endZoneOffset = null, + ) + BODY_TEMPERATURE -> + BodyTemperatureRecord( + time = + Instant.ofEpochMilli( + startTime + ), + temperature = + Temperature.celsius( + value + ), + zoneOffset = null, + ) + BODY_WATER_MASS -> + BodyWaterMassRecord( + time = + Instant.ofEpochMilli( + startTime + ), + mass = + Mass.kilograms( + value + ), + zoneOffset = null, + ) + BLOOD_OXYGEN -> + OxygenSaturationRecord( + time = + Instant.ofEpochMilli( + startTime + ), + percentage = + Percentage( + value + ), + zoneOffset = null, + ) + BLOOD_GLUCOSE -> + BloodGlucoseRecord( + time = + Instant.ofEpochMilli( + startTime + ), + level = + BloodGlucose.milligramsPerDeciliter( + value + ), + zoneOffset = null, + ) + DISTANCE_DELTA -> + DistanceRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + distance = + Length.meters( + value + ), + startZoneOffset = null, + endZoneOffset = null, + ) + WATER -> + HydrationRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + volume = + Volume.liters( + value + ), + startZoneOffset = null, + endZoneOffset = null, + ) + SLEEP_ASLEEP -> + SleepSessionRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + startZoneOffset = null, + endZoneOffset = null, + stages = + listOf( + SleepSessionRecord + .Stage( + Instant.ofEpochMilli( + startTime + ), + Instant.ofEpochMilli( + endTime + ), + SleepSessionRecord + .STAGE_TYPE_SLEEPING + ) + ), + ) + SLEEP_LIGHT -> + SleepSessionRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + startZoneOffset = null, + endZoneOffset = null, + stages = + listOf( + SleepSessionRecord + .Stage( + Instant.ofEpochMilli( + startTime + ), + Instant.ofEpochMilli( + endTime + ), + SleepSessionRecord + .STAGE_TYPE_LIGHT + ) + ), + ) + SLEEP_DEEP -> + SleepSessionRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + startZoneOffset = null, + endZoneOffset = null, + stages = + listOf( + SleepSessionRecord + .Stage( + Instant.ofEpochMilli( + startTime + ), + Instant.ofEpochMilli( + endTime + ), + SleepSessionRecord + .STAGE_TYPE_DEEP + ) + ), + ) + SLEEP_REM -> + SleepSessionRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + startZoneOffset = null, + endZoneOffset = null, + stages = + listOf( + SleepSessionRecord + .Stage( + Instant.ofEpochMilli( + startTime + ), + Instant.ofEpochMilli( + endTime + ), + SleepSessionRecord + .STAGE_TYPE_REM + ) + ), + ) + SLEEP_OUT_OF_BED -> + SleepSessionRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + startZoneOffset = null, + endZoneOffset = null, + stages = + listOf( + SleepSessionRecord + .Stage( + Instant.ofEpochMilli( + startTime + ), + Instant.ofEpochMilli( + endTime + ), + SleepSessionRecord + .STAGE_TYPE_OUT_OF_BED + ) + ), + ) + SLEEP_AWAKE -> + SleepSessionRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + startZoneOffset = null, + endZoneOffset = null, + stages = + listOf( + SleepSessionRecord + .Stage( + Instant.ofEpochMilli( + startTime + ), + Instant.ofEpochMilli( + endTime + ), + SleepSessionRecord + .STAGE_TYPE_AWAKE + ) + ), + ) + SLEEP_SESSION -> + SleepSessionRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + startZoneOffset = null, + endZoneOffset = null, + ) + RESTING_HEART_RATE -> + RestingHeartRateRecord( + time = + Instant.ofEpochMilli( + startTime + ), + beatsPerMinute = + value.toLong(), + zoneOffset = null, + ) + BASAL_ENERGY_BURNED -> + BasalMetabolicRateRecord( + time = + Instant.ofEpochMilli( + startTime + ), + basalMetabolicRate = + Power.kilocaloriesPerDay( + value + ), + zoneOffset = null, + ) + FLIGHTS_CLIMBED -> + FloorsClimbedRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + floors = value, + startZoneOffset = null, + endZoneOffset = null, + ) + RESPIRATORY_RATE -> + RespiratoryRateRecord( + time = + Instant.ofEpochMilli( + startTime + ), + rate = value, + zoneOffset = null, + ) + // AGGREGATE_STEP_COUNT -> StepsRecord() + TOTAL_CALORIES_BURNED -> + TotalCaloriesBurnedRecord( + startTime = + Instant.ofEpochMilli( + startTime + ), + endTime = + Instant.ofEpochMilli( + endTime + ), + energy = + Energy.kilocalories( + value + ), + startZoneOffset = null, + endZoneOffset = null, + ) + BLOOD_PRESSURE_SYSTOLIC -> + throw IllegalArgumentException( + "You must use the [writeBloodPressure] API " + ) + BLOOD_PRESSURE_DIASTOLIC -> + throw IllegalArgumentException( + "You must use the [writeBloodPressure] API " + ) + WORKOUT -> + throw IllegalArgumentException( + "You must use the [writeWorkoutData] API " + ) + NUTRITION -> + throw IllegalArgumentException( + "You must use the [writeMeal] API " + ) + else -> + throw IllegalArgumentException( + "The type $type was not supported by the Health plugin or you must use another API " + ) + } + scope.launch { + try { + healthConnectClient.insertRecords(listOf(record)) + result.success(true) + } catch (e: Exception) { + result.success(false) + } } - if (totalEnergyBurned != null) { - list.add( - TotalCaloriesBurnedRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - energy = Energy.kilocalories(totalEnergyBurned.toDouble()), - ), - ) - } - healthConnectClient.insertRecords( - list, - ) - result.success(true) - Log.i("FLUTTER_HEALTH::SUCCESS", "[Health Connect] Workout was successfully added!") - } catch (e: Exception) { - Log.w( - "FLUTTER_HEALTH::ERROR", - "[Health Connect] There was an error adding the workout", - ) - Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") - Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) - result.success(false) - } } - } - - fun writeBloodPressureHC(call: MethodCall, result: Result) { - val systolic = call.argument("systolic")!! - val diastolic = call.argument("diastolic")!! - val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) - val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) - - scope.launch { - try { - healthConnectClient.insertRecords( - listOf( - BloodPressureRecord( - time = startTime, - systolic = Pressure.millimetersOfMercury(systolic), - diastolic = Pressure.millimetersOfMercury(diastolic), - zoneOffset = null, - ), - ), - ) - result.success(true) - Log.i( - "FLUTTER_HEALTH::SUCCESS", - "[Health Connect] Blood pressure was successfully added!", - ) - } catch (e: Exception) { - Log.w( - "FLUTTER_HEALTH::ERROR", - "[Health Connect] There was an error adding the blood pressure", - ) - Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") - Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) - result.success(false) - } + + fun writeWorkoutHCData(call: MethodCall, result: Result) { + val type = call.argument("activityType")!! + val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) + val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) + val totalEnergyBurned = call.argument("totalEnergyBurned") + val totalDistance = call.argument("totalDistance") + if (workoutTypeMapHealthConnect.containsKey(type) == false) { + result.success(false) + Log.w( + "FLUTTER_HEALTH::ERROR", + "[Health Connect] Workout type not supported" + ) + return + } + val workoutType = workoutTypeMapHealthConnect[type]!! + val title = call.argument("title") ?: type + + scope.launch { + try { + val list = mutableListOf() + list.add( + ExerciseSessionRecord( + startTime = startTime, + startZoneOffset = null, + endTime = endTime, + endZoneOffset = null, + exerciseType = workoutType, + title = title, + ), + ) + if (totalDistance != null) { + list.add( + DistanceRecord( + startTime = startTime, + startZoneOffset = null, + endTime = endTime, + endZoneOffset = null, + distance = + Length.meters( + totalDistance.toDouble() + ), + ), + ) + } + if (totalEnergyBurned != null) { + list.add( + TotalCaloriesBurnedRecord( + startTime = startTime, + startZoneOffset = null, + endTime = endTime, + endZoneOffset = null, + energy = + Energy.kilocalories( + totalEnergyBurned + .toDouble() + ), + ), + ) + } + healthConnectClient.insertRecords( + list, + ) + result.success(true) + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "[Health Connect] Workout was successfully added!" + ) + } catch (e: Exception) { + Log.w( + "FLUTTER_HEALTH::ERROR", + "[Health Connect] There was an error adding the workout", + ) + Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") + Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) + result.success(false) + } + } } - } - - fun deleteHCData(call: MethodCall, result: Result) { - val type = call.argument("dataTypeKey")!! - val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) - val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) - if(!MapToHCType.containsKey(type)) { - Log.w("FLUTTER_HEALTH::ERROR", "Datatype " + type + " not found in HC") - result.success(false) - return + + fun writeBloodPressureHC(call: MethodCall, result: Result) { + val systolic = call.argument("systolic")!! + val diastolic = call.argument("diastolic")!! + val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) + val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) + + scope.launch { + try { + healthConnectClient.insertRecords( + listOf( + BloodPressureRecord( + time = startTime, + systolic = + Pressure.millimetersOfMercury( + systolic + ), + diastolic = + Pressure.millimetersOfMercury( + diastolic + ), + zoneOffset = null, + ), + ), + ) + result.success(true) + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "[Health Connect] Blood pressure was successfully added!", + ) + } catch (e: Exception) { + Log.w( + "FLUTTER_HEALTH::ERROR", + "[Health Connect] There was an error adding the blood pressure", + ) + Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") + Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) + result.success(false) + } + } } - val classType = MapToHCType[type]!! - scope.launch { - try { - healthConnectClient.deleteRecords( - recordType = classType, - timeRangeFilter = TimeRangeFilter.between(startTime, endTime), - ) - result.success(true) - } catch (e: Exception) { - result.success(false) - } + fun deleteHCData(call: MethodCall, result: Result) { + val type = call.argument("dataTypeKey")!! + val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) + val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) + if (!MapToHCType.containsKey(type)) { + Log.w("FLUTTER_HEALTH::ERROR", "Datatype " + type + " not found in HC") + result.success(false) + return + } + val classType = MapToHCType[type]!! + + scope.launch { + try { + healthConnectClient.deleteRecords( + recordType = classType, + timeRangeFilter = + TimeRangeFilter.between( + startTime, + endTime + ), + ) + result.success(true) + } catch (e: Exception) { + result.success(false) + } + } } - } - - val MapSleepStageToType = hashMapOf( - 1 to SLEEP_AWAKE, - 2 to SLEEP_ASLEEP, - 3 to SLEEP_OUT_OF_BED, - 4 to SLEEP_LIGHT, - 5 to SLEEP_DEEP, - 6 to SLEEP_REM, - ) - - private val MapMealTypeToTypeHC = hashMapOf( - BREAKFAST to MEAL_TYPE_BREAKFAST, - LUNCH to MEAL_TYPE_LUNCH, - DINNER to MEAL_TYPE_DINNER, - SNACK to MEAL_TYPE_SNACK, - MEAL_UNKNOWN to MEAL_TYPE_UNKNOWN, - ) - - private val MapTypeToMealTypeHC = hashMapOf( - MEAL_TYPE_BREAKFAST to BREAKFAST, - MEAL_TYPE_LUNCH to LUNCH, - MEAL_TYPE_DINNER to DINNER, - MEAL_TYPE_SNACK to SNACK, - MEAL_TYPE_UNKNOWN to MEAL_UNKNOWN, - ) - - private val MapMealTypeToType = hashMapOf( - BREAKFAST to Field.MEAL_TYPE_BREAKFAST, - LUNCH to Field.MEAL_TYPE_LUNCH, - DINNER to Field.MEAL_TYPE_DINNER, - SNACK to Field.MEAL_TYPE_SNACK, - MEAL_UNKNOWN to Field.MEAL_TYPE_UNKNOWN, - ) - - val MapToHCType = hashMapOf( - BODY_FAT_PERCENTAGE to BodyFatRecord::class, - HEIGHT to HeightRecord::class, - WEIGHT to WeightRecord::class, - STEPS to StepsRecord::class, - AGGREGATE_STEP_COUNT to StepsRecord::class, - ACTIVE_ENERGY_BURNED to ActiveCaloriesBurnedRecord::class, - HEART_RATE to HeartRateRecord::class, - BODY_TEMPERATURE to BodyTemperatureRecord::class, - BLOOD_PRESSURE_SYSTOLIC to BloodPressureRecord::class, - BLOOD_PRESSURE_DIASTOLIC to BloodPressureRecord::class, - BLOOD_OXYGEN to OxygenSaturationRecord::class, - BLOOD_GLUCOSE to BloodGlucoseRecord::class, - DISTANCE_DELTA to DistanceRecord::class, - WATER to HydrationRecord::class, - SLEEP_ASLEEP to SleepSessionRecord::class, - SLEEP_AWAKE to SleepSessionRecord::class, - SLEEP_LIGHT to SleepSessionRecord::class, - SLEEP_DEEP to SleepSessionRecord::class, - SLEEP_REM to SleepSessionRecord::class, - SLEEP_OUT_OF_BED to SleepSessionRecord::class, - SLEEP_SESSION to SleepSessionRecord::class, - WORKOUT to ExerciseSessionRecord::class, - NUTRITION to NutritionRecord::class, - RESTING_HEART_RATE to RestingHeartRateRecord::class, - BASAL_ENERGY_BURNED to BasalMetabolicRateRecord::class, - FLIGHTS_CLIMBED to FloorsClimbedRecord::class, - RESPIRATORY_RATE to RespiratoryRateRecord::class, - // MOVE_MINUTES to TODO: Find alternative? - // TODO: Implement remaining types - // "ActiveCaloriesBurned" to ActiveCaloriesBurnedRecord::class, - // "BasalBodyTemperature" to BasalBodyTemperatureRecord::class, - // "BasalMetabolicRate" to BasalMetabolicRateRecord::class, - // "BloodGlucose" to BloodGlucoseRecord::class, - // "BloodPressure" to BloodPressureRecord::class, - // "BodyFat" to BodyFatRecord::class, - // "BodyTemperature" to BodyTemperatureRecord::class, - // "BoneMass" to BoneMassRecord::class, - // "CervicalMucus" to CervicalMucusRecord::class, - // "CyclingPedalingCadence" to CyclingPedalingCadenceRecord::class, - // "Distance" to DistanceRecord::class, - // "ElevationGained" to ElevationGainedRecord::class, - // "ExerciseSession" to ExerciseSessionRecord::class, - // "FloorsClimbed" to FloorsClimbedRecord::class, - // "HeartRate" to HeartRateRecord::class, - // "Height" to HeightRecord::class, - // "Hydration" to HydrationRecord::class, - // "LeanBodyMass" to LeanBodyMassRecord::class, - // "MenstruationFlow" to MenstruationFlowRecord::class, - // "MenstruationPeriod" to MenstruationPeriodRecord::class, - // "Nutrition" to NutritionRecord::class, - // "OvulationTest" to OvulationTestRecord::class, - // "OxygenSaturation" to OxygenSaturationRecord::class, - // "Power" to PowerRecord::class, - // "RespiratoryRate" to RespiratoryRateRecord::class, - // "RestingHeartRate" to RestingHeartRateRecord::class, - // "SexualActivity" to SexualActivityRecord::class, - // "SleepSession" to SleepSessionRecord::class, - // "SleepStage" to SleepStageRecord::class, - // "Speed" to SpeedRecord::class, - // "StepsCadence" to StepsCadenceRecord::class, - // "Steps" to StepsRecord::class, - // "TotalCaloriesBurned" to TotalCaloriesBurnedRecord::class, - // "Vo2Max" to Vo2MaxRecord::class, - // "Weight" to WeightRecord::class, - // "WheelchairPushes" to WheelchairPushesRecord::class, - ) + + val MapSleepStageToType = + hashMapOf( + 1 to SLEEP_AWAKE, + 2 to SLEEP_ASLEEP, + 3 to SLEEP_OUT_OF_BED, + 4 to SLEEP_LIGHT, + 5 to SLEEP_DEEP, + 6 to SLEEP_REM, + ) + + private val MapMealTypeToTypeHC = + hashMapOf( + BREAKFAST to MEAL_TYPE_BREAKFAST, + LUNCH to MEAL_TYPE_LUNCH, + DINNER to MEAL_TYPE_DINNER, + SNACK to MEAL_TYPE_SNACK, + MEAL_UNKNOWN to MEAL_TYPE_UNKNOWN, + ) + + private val MapTypeToMealTypeHC = + hashMapOf( + MEAL_TYPE_BREAKFAST to BREAKFAST, + MEAL_TYPE_LUNCH to LUNCH, + MEAL_TYPE_DINNER to DINNER, + MEAL_TYPE_SNACK to SNACK, + MEAL_TYPE_UNKNOWN to MEAL_UNKNOWN, + ) + + private val MapMealTypeToType = + hashMapOf( + BREAKFAST to Field.MEAL_TYPE_BREAKFAST, + LUNCH to Field.MEAL_TYPE_LUNCH, + DINNER to Field.MEAL_TYPE_DINNER, + SNACK to Field.MEAL_TYPE_SNACK, + MEAL_UNKNOWN to Field.MEAL_TYPE_UNKNOWN, + ) + + val MapToHCType = + hashMapOf( + BODY_FAT_PERCENTAGE to BodyFatRecord::class, + HEIGHT to HeightRecord::class, + WEIGHT to WeightRecord::class, + STEPS to StepsRecord::class, + AGGREGATE_STEP_COUNT to StepsRecord::class, + ACTIVE_ENERGY_BURNED to ActiveCaloriesBurnedRecord::class, + HEART_RATE to HeartRateRecord::class, + BODY_TEMPERATURE to BodyTemperatureRecord::class, + BODY_WATER_MASS to BodyWaterMassRecord::class, + BLOOD_PRESSURE_SYSTOLIC to BloodPressureRecord::class, + BLOOD_PRESSURE_DIASTOLIC to BloodPressureRecord::class, + BLOOD_OXYGEN to OxygenSaturationRecord::class, + BLOOD_GLUCOSE to BloodGlucoseRecord::class, + DISTANCE_DELTA to DistanceRecord::class, + WATER to HydrationRecord::class, + SLEEP_ASLEEP to SleepSessionRecord::class, + SLEEP_AWAKE to SleepSessionRecord::class, + SLEEP_LIGHT to SleepSessionRecord::class, + SLEEP_DEEP to SleepSessionRecord::class, + SLEEP_REM to SleepSessionRecord::class, + SLEEP_OUT_OF_BED to SleepSessionRecord::class, + SLEEP_SESSION to SleepSessionRecord::class, + WORKOUT to ExerciseSessionRecord::class, + NUTRITION to NutritionRecord::class, + RESTING_HEART_RATE to RestingHeartRateRecord::class, + BASAL_ENERGY_BURNED to BasalMetabolicRateRecord::class, + FLIGHTS_CLIMBED to FloorsClimbedRecord::class, + RESPIRATORY_RATE to RespiratoryRateRecord::class, + TOTAL_CALORIES_BURNED to TotalCaloriesBurnedRecord::class + // MOVE_MINUTES to TODO: Find alternative? + // TODO: Implement remaining types + // "ActiveCaloriesBurned" to + // ActiveCaloriesBurnedRecord::class, + // "BasalBodyTemperature" to + // BasalBodyTemperatureRecord::class, + // "BasalMetabolicRate" to BasalMetabolicRateRecord::class, + // "BloodGlucose" to BloodGlucoseRecord::class, + // "BloodPressure" to BloodPressureRecord::class, + // "BodyFat" to BodyFatRecord::class, + // "BodyTemperature" to BodyTemperatureRecord::class, + // "BoneMass" to BoneMassRecord::class, + // "CervicalMucus" to CervicalMucusRecord::class, + // "CyclingPedalingCadence" to + // CyclingPedalingCadenceRecord::class, + // "Distance" to DistanceRecord::class, + // "ElevationGained" to ElevationGainedRecord::class, + // "ExerciseSession" to ExerciseSessionRecord::class, + // "FloorsClimbed" to FloorsClimbedRecord::class, + // "HeartRate" to HeartRateRecord::class, + // "Height" to HeightRecord::class, + // "Hydration" to HydrationRecord::class, + // "LeanBodyMass" to LeanBodyMassRecord::class, + // "MenstruationFlow" to MenstruationFlowRecord::class, + // "MenstruationPeriod" to MenstruationPeriodRecord::class, + // "Nutrition" to NutritionRecord::class, + // "OvulationTest" to OvulationTestRecord::class, + // "OxygenSaturation" to OxygenSaturationRecord::class, + // "Power" to PowerRecord::class, + // "RespiratoryRate" to RespiratoryRateRecord::class, + // "RestingHeartRate" to RestingHeartRateRecord::class, + // "SexualActivity" to SexualActivityRecord::class, + // "SleepSession" to SleepSessionRecord::class, + // "SleepStage" to SleepStageRecord::class, + // "Speed" to SpeedRecord::class, + // "StepsCadence" to StepsCadenceRecord::class, + // "Steps" to StepsRecord::class, + // "TotalCaloriesBurned" to + // TotalCaloriesBurnedRecord::class, + // "Vo2Max" to Vo2MaxRecord::class, + // "Weight" to WeightRecord::class, + // "WheelchairPushes" to WheelchairPushesRecord::class, + ) + + val MapToHCAggregateMetric = + hashMapOf( + HEIGHT to HeightRecord.HEIGHT_AVG, + WEIGHT to WeightRecord.WEIGHT_AVG, + STEPS to StepsRecord.COUNT_TOTAL, + AGGREGATE_STEP_COUNT to StepsRecord.COUNT_TOTAL, + ACTIVE_ENERGY_BURNED to + ActiveCaloriesBurnedRecord + .ACTIVE_CALORIES_TOTAL, + HEART_RATE to HeartRateRecord.MEASUREMENTS_COUNT, + DISTANCE_DELTA to DistanceRecord.DISTANCE_TOTAL, + WATER to HydrationRecord.VOLUME_TOTAL, + SLEEP_ASLEEP to SleepSessionRecord.SLEEP_DURATION_TOTAL, + SLEEP_AWAKE to SleepSessionRecord.SLEEP_DURATION_TOTAL, + SLEEP_IN_BED to SleepSessionRecord.SLEEP_DURATION_TOTAL, + TOTAL_CALORIES_BURNED to + TotalCaloriesBurnedRecord.ENERGY_TOTAL + ) } diff --git a/packages/health/example/ios/Flutter/AppFrameworkInfo.plist b/packages/health/example/ios/Flutter/AppFrameworkInfo.plist index 4f8d4d245..8c6e56146 100644 --- a/packages/health/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/health/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 11.0 + 12.0 diff --git a/packages/health/example/ios/Runner.xcodeproj/project.pbxproj b/packages/health/example/ios/Runner.xcodeproj/project.pbxproj index db9dbd7a3..4ce55d34f 100644 --- a/packages/health/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/health/example/ios/Runner.xcodeproj/project.pbxproj @@ -171,7 +171,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = "The Chromium Authors"; TargetAttributes = { 97C146ED1CF9000F007C117D = { diff --git a/packages/health/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/health/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a6b826db2..5e31d3d34 100644 --- a/packages/health/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/health/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ runApp(HealthApp()); @@ -24,6 +26,7 @@ enum AppState { DATA_NOT_ADDED, DATA_NOT_DELETED, STEPS_READY, + HEALTH_CONNECT_STATUS, } class _HealthAppState extends State { @@ -31,12 +34,14 @@ class _HealthAppState extends State { AppState _state = AppState.DATA_NOT_FETCHED; int _nofSteps = 0; - // Define the types to get. + // All types available depending on platform (iOS ot Android). + List get types => (Platform.isAndroid) + ? dataTypesAndroid + : (Platform.isIOS) + ? dataTypesIOS + : []; - // Use the entire list on e.g. Android. - static final types = dataTypesIOS; - - // Or specify specific types + // // Or specify specific types // static final types = [ // HealthDataType.WEIGHT, // HealthDataType.STEPS, @@ -51,16 +56,27 @@ class _HealthAppState extends State { // Set up corresponding permissions // READ only - final permissions = types.map((e) => HealthDataAccess.READ).toList(); + List get permissions => + types.map((e) => HealthDataAccess.READ).toList(); // Or both READ and WRITE - // final permissions = types.map((e) => HealthDataAccess.READ_WRITE).toList(); + // List get permissions => + // types.map((e) => HealthDataAccess.READ_WRITE).toList(); + + void initState() { + // configure the health plugin before use. + Health().configure(useHealthConnectIfAvailable: true); - // create a HealthFactory for use in the app - HealthFactory health = HealthFactory(useHealthConnectIfAvailable: true); + super.initState(); + } + + /// Install Google Health Connect on this phone. + Future installHealthConnect() async { + await Health().installHealthConnect(); + } /// Authorize, i.e. get permissions to access relevant health data. - Future authorize() async { + Future authorize() async { // If we are trying to read Step Count, Workout, Sleep or other data that requires // the ACTIVITY_RECOGNITION permission, we need to request the permission first. // This requires a special request authorization call. @@ -71,7 +87,7 @@ class _HealthAppState extends State { // Check if we have health permissions bool? hasPermissions = - await health.hasPermissions(types, permissions: permissions); + await Health().hasPermissions(types, permissions: permissions); // hasPermissions = false because the hasPermission cannot disclose if WRITE access exists. // Hence, we have to request with WRITE as well. @@ -81,10 +97,10 @@ class _HealthAppState extends State { if (!hasPermissions) { // requesting access to the data types before reading them try { - authorized = - await health.requestAuthorization(types, permissions: permissions); + authorized = await Health() + .requestAuthorization(types, permissions: permissions); } catch (error) { - print("Exception in authorize: $error"); + debugPrint("Exception in authorize: $error"); } } @@ -92,8 +108,20 @@ class _HealthAppState extends State { (authorized) ? AppState.AUTHORIZED : AppState.AUTH_NOT_GRANTED); } + /// Gets the Health Connect status on Android. + Future getHealthConnectSdkStatus() async { + assert(Platform.isAndroid, "This is only available on Android"); + + final status = await Health().getHealthConnectSdkStatus(); + + setState(() { + _contentHealthConnectStatus = Text('Health Connect Status: $status'); + _state = AppState.HEALTH_CONNECT_STATUS; + }); + } + /// Fetch data points from the health plugin and show them in the app. - Future fetchData() async { + Future fetchData() async { setState(() => _state = AppState.FETCHING_DATA); // get data within the last 24 hours @@ -105,20 +133,26 @@ class _HealthAppState extends State { try { // fetch health data - List healthData = - await health.getHealthDataFromTypes(yesterday, now, types); + List healthData = await Health().getHealthDataFromTypes( + types: types, + startTime: yesterday, + endTime: now, + ); + + debugPrint('Total number of data points: ${healthData.length}. ' + '${healthData.length > 100 ? 'Only showing the first 100.' : ''}'); + // save all the new data points (only the first 100) _healthDataList.addAll( (healthData.length < 100) ? healthData : healthData.sublist(0, 100)); } catch (error) { - print("Exception in getHealthDataFromTypes: $error"); + debugPrint("Exception in getHealthDataFromTypes: $error"); } // filter out duplicates - _healthDataList = HealthFactory.removeDuplicates(_healthDataList); + _healthDataList = Health().removeDuplicates(_healthDataList); - // print the results - _healthDataList.forEach((x) => print(x)); + _healthDataList.forEach((data) => debugPrint(toJsonString(data))); // update the UI to display the results setState(() { @@ -127,7 +161,9 @@ class _HealthAppState extends State { } /// Add some random health data. - Future addData() async { + /// Note that you should ensure that you have permissions to add the + /// following data types. + Future addData() async { final now = DateTime.now(); final earlier = now.subtract(Duration(minutes: 20)); @@ -136,43 +172,110 @@ class _HealthAppState extends State { // Both Android's Google Fit and iOS' HealthKit have more types that we support in the enum list [HealthDataType] // Add more - like AUDIOGRAM, HEADACHE_SEVERE etc. to try them. bool success = true; - success &= await health.writeHealthData( - 1.925, HealthDataType.HEIGHT, earlier, now); - success &= - await health.writeHealthData(90, HealthDataType.WEIGHT, earlier, now); - success &= await health.writeHealthData( - 90, HealthDataType.HEART_RATE, earlier, now); - success &= - await health.writeHealthData(90, HealthDataType.STEPS, earlier, now); - success &= await health.writeHealthData( - 200, HealthDataType.ACTIVE_ENERGY_BURNED, earlier, now); - success &= await health.writeHealthData( - 70, HealthDataType.HEART_RATE, earlier, now); - success &= await health.writeHealthData( - 37, HealthDataType.BODY_TEMPERATURE, earlier, now); - success &= await health.writeBloodOxygen(98, earlier, now, flowRate: 1.0); - success &= await health.writeHealthData( - 105, HealthDataType.BLOOD_GLUCOSE, earlier, now); - success &= - await health.writeHealthData(1.8, HealthDataType.WATER, earlier, now); - success &= await health.writeWorkoutData( - HealthWorkoutActivityType.AMERICAN_FOOTBALL, - now.subtract(Duration(minutes: 15)), - now, - totalDistance: 2430, - totalEnergyBurned: 400); - success &= await health.writeBloodPressure(90, 80, earlier, now); - success &= await health.writeHealthData( - 0.0, HealthDataType.SLEEP_DEEP, earlier, now); - success &= await health.writeMeal( - earlier, now, 1000, 50, 25, 50, "Banana", MealType.SNACK); - // Store an Audiogram - // Uncomment these on iOS - only available on iOS + + // misc. health data examples using the writeHealthData() method + success &= await Health().writeHealthData( + value: 1.925, + type: HealthDataType.HEIGHT, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 90, type: HealthDataType.WEIGHT, startTime: now); + success &= await Health().writeHealthData( + value: 90, + type: HealthDataType.HEART_RATE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 90, + type: HealthDataType.STEPS, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 200, + type: HealthDataType.ACTIVE_ENERGY_BURNED, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 70, + type: HealthDataType.HEART_RATE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 37, + type: HealthDataType.BODY_TEMPERATURE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 105, + type: HealthDataType.BLOOD_GLUCOSE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 1.8, + type: HealthDataType.WATER, + startTime: earlier, + endTime: now); + + // different types of sleep + success &= await Health().writeHealthData( + value: 0.0, + type: HealthDataType.SLEEP_REM, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 0.0, + type: HealthDataType.SLEEP_ASLEEP, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 0.0, + type: HealthDataType.SLEEP_AWAKE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 0.0, + type: HealthDataType.SLEEP_DEEP, + startTime: earlier, + endTime: now); + + // specialized write methods + success &= await Health().writeBloodOxygen( + saturation: 98, + startTime: earlier, + endTime: now, + flowRate: 1.0, + ); + success &= await Health().writeWorkoutData( + activityType: HealthWorkoutActivityType.AMERICAN_FOOTBALL, + title: "Random workout name that shows up in Health Connect", + start: now.subtract(Duration(minutes: 15)), + end: now, + totalDistance: 2430, + totalEnergyBurned: 400, + ); + success &= await Health().writeBloodPressure( + systolic: 90, + diastolic: 80, + startTime: now, + ); + success &= await Health().writeMeal( + mealType: MealType.SNACK, + startTime: earlier, + endTime: now, + caloriesConsumed: 1000, + carbohydrates: 50, + protein: 25, + fatTotal: 50, + name: "Banana", + caffeine: 0.002, + ); + + // Store an Audiogram - only available on iOS // const frequencies = [125.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0]; // const leftEarSensitivities = [49.0, 54.0, 89.0, 52.0, 77.0, 35.0]; // const rightEarSensitivities = [76.0, 66.0, 90.0, 22.0, 85.0, 44.5]; - - // success &= await health.writeAudiogram( + // success &= await Health().writeAudiogram( // frequencies, // leftEarSensitivities, // rightEarSensitivities, @@ -190,13 +293,17 @@ class _HealthAppState extends State { } /// Delete some random health data. - Future deleteData() async { + Future deleteData() async { final now = DateTime.now(); final earlier = now.subtract(Duration(hours: 24)); bool success = true; for (HealthDataType type in types) { - success &= await health.delete(type, earlier, now); + success &= await Health().delete( + type: type, + startTime: earlier, + endTime: now, + ); } setState(() { @@ -205,7 +312,7 @@ class _HealthAppState extends State { } /// Fetch steps from the health plugin and show them in the app. - Future fetchStepData() async { + Future fetchStepData() async { int? steps; // get steps for today (i.e., since midnight) @@ -213,162 +320,41 @@ class _HealthAppState extends State { final midnight = DateTime(now.year, now.month, now.day); bool stepsPermission = - await health.hasPermissions([HealthDataType.STEPS]) ?? false; + await Health().hasPermissions([HealthDataType.STEPS]) ?? false; if (!stepsPermission) { stepsPermission = - await health.requestAuthorization([HealthDataType.STEPS]); + await Health().requestAuthorization([HealthDataType.STEPS]); } if (stepsPermission) { try { - steps = await health.getTotalStepsInInterval(midnight, now); + steps = await Health().getTotalStepsInInterval(midnight, now); } catch (error) { - print("Caught exception in getTotalStepsInInterval: $error"); + debugPrint("Exception in getTotalStepsInInterval: $error"); } - print('Total number of steps: $steps'); + debugPrint('Total number of steps: $steps'); setState(() { _nofSteps = (steps == null) ? 0 : steps; _state = (steps == null) ? AppState.NO_DATA : AppState.STEPS_READY; }); } else { - print("Authorization not granted - error in authorization"); + debugPrint("Authorization not granted - error in authorization"); setState(() => _state = AppState.DATA_NOT_FETCHED); } } /// Revoke access to health data. Note, this only has an effect on Android. - Future revokeAccess() async { + Future revokeAccess() async { try { - await health.revokePermissions(); + await Health().revokePermissions(); } catch (error) { - print("Caught exception in revokeAccess: $error"); + debugPrint("Exception in revokeAccess: $error"); } } - Widget _contentFetchingData() { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: EdgeInsets.all(20), - child: CircularProgressIndicator( - strokeWidth: 10, - )), - Text('Fetching data...') - ], - ); - } - - Widget _contentDataReady() { - return ListView.builder( - itemCount: _healthDataList.length, - itemBuilder: (_, index) { - HealthDataPoint p = _healthDataList[index]; - if (p.value is AudiogramHealthValue) { - return ListTile( - title: Text("${p.typeString}: ${p.value}"), - trailing: Text('${p.unitString}'), - subtitle: Text('${p.dateFrom} - ${p.dateTo}'), - ); - } - if (p.value is WorkoutHealthValue) { - return ListTile( - title: Text( - "${p.typeString}: ${(p.value as WorkoutHealthValue).totalEnergyBurned} ${(p.value as WorkoutHealthValue).totalEnergyBurnedUnit?.name}"), - trailing: Text( - '${(p.value as WorkoutHealthValue).workoutActivityType.name}'), - subtitle: Text('${p.dateFrom} - ${p.dateTo}'), - ); - } - if (p.value is NutritionHealthValue) { - return ListTile( - title: Text( - "${p.typeString} ${(p.value as NutritionHealthValue).mealType}: ${(p.value as NutritionHealthValue).name}"), - trailing: - Text('${(p.value as NutritionHealthValue).calories} kcal'), - subtitle: Text('${p.dateFrom} - ${p.dateTo}'), - ); - } - return ListTile( - title: Text("${p.typeString}: ${p.value}"), - trailing: Text('${p.unitString}'), - subtitle: Text('${p.dateFrom} - ${p.dateTo}'), - ); - }); - } - - Widget _contentNoData() { - return Text('No Data to show'); - } - - Widget _contentNotFetched() { - return Column( - children: [ - Text("Press 'Auth' to get permissions to access health data."), - Text("Press 'Fetch Dat' to get health data."), - Text("Press 'Add Data' to add some random health data."), - Text("Press 'Delete Data' to remove some random health data."), - ], - mainAxisAlignment: MainAxisAlignment.center, - ); - } - - Widget _authorized() { - return Text('Authorization granted!'); - } - - Widget _authorizationNotGranted() { - return Text('Authorization not given. ' - 'For Android please check your OAUTH2 client ID is correct in Google Developer Console. ' - 'For iOS check your permissions in Apple Health.'); - } - - Widget _dataAdded() { - return Text('Data points inserted successfully!'); - } - - Widget _dataDeleted() { - return Text('Data points deleted successfully!'); - } - - Widget _stepsFetched() { - return Text('Total number of steps: $_nofSteps'); - } - - Widget _dataNotAdded() { - return Text('Failed to add data'); - } - - Widget _dataNotDeleted() { - return Text('Failed to delete data'); - } - - Widget _content() { - if (_state == AppState.DATA_READY) - return _contentDataReady(); - else if (_state == AppState.NO_DATA) - return _contentNoData(); - else if (_state == AppState.FETCHING_DATA) - return _contentFetchingData(); - else if (_state == AppState.AUTHORIZED) - return _authorized(); - else if (_state == AppState.AUTH_NOT_GRANTED) - return _authorizationNotGranted(); - else if (_state == AppState.DATA_ADDED) - return _dataAdded(); - else if (_state == AppState.DATA_DELETED) - return _dataDeleted(); - else if (_state == AppState.STEPS_READY) - return _stepsFetched(); - else if (_state == AppState.DATA_NOT_ADDED) - return _dataNotAdded(); - else if (_state == AppState.DATA_NOT_DELETED) - return _dataNotDeleted(); - else - return _contentNotFetched(); - } + // UI building below @override Widget build(BuildContext context) { @@ -385,11 +371,19 @@ class _HealthAppState extends State { children: [ TextButton( onPressed: authorize, - child: - Text("Auth", style: TextStyle(color: Colors.white)), + child: Text("Authenticate", + style: TextStyle(color: Colors.white)), style: ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.blue))), + if (Platform.isAndroid) + TextButton( + onPressed: getHealthConnectSdkStatus, + child: Text("Check Health Connect Status", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), TextButton( onPressed: fetchData, child: Text("Fetch Data", @@ -425,14 +419,122 @@ class _HealthAppState extends State { style: ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.blue))), + if (Platform.isAndroid) + TextButton( + onPressed: installHealthConnect, + child: Text("Install Health Connect", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), ], ), Divider(thickness: 3), - Expanded(child: Center(child: _content())) + Expanded(child: Center(child: _content)) ], ), ), ), ); } + + Widget get _contentFetchingData => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: EdgeInsets.all(20), + child: CircularProgressIndicator( + strokeWidth: 10, + )), + Text('Fetching data...') + ], + ); + + Widget get _contentDataReady => ListView.builder( + itemCount: _healthDataList.length, + itemBuilder: (_, index) { + HealthDataPoint p = _healthDataList[index]; + if (p.value is AudiogramHealthValue) { + return ListTile( + title: Text("${p.typeString}: ${p.value}"), + trailing: Text('${p.unitString}'), + subtitle: Text('${p.dateFrom} - ${p.dateTo}'), + ); + } + if (p.value is WorkoutHealthValue) { + return ListTile( + title: Text( + "${p.typeString}: ${(p.value as WorkoutHealthValue).totalEnergyBurned} ${(p.value as WorkoutHealthValue).totalEnergyBurnedUnit?.name}"), + trailing: Text( + '${(p.value as WorkoutHealthValue).workoutActivityType.name}'), + subtitle: Text('${p.dateFrom} - ${p.dateTo}'), + ); + } + if (p.value is NutritionHealthValue) { + return ListTile( + title: Text( + "${p.typeString} ${(p.value as NutritionHealthValue).mealType}: ${(p.value as NutritionHealthValue).name}"), + trailing: + Text('${(p.value as NutritionHealthValue).calories} kcal'), + subtitle: Text('${p.dateFrom} - ${p.dateTo}'), + ); + } + return ListTile( + title: Text("${p.typeString}: ${p.value}"), + trailing: Text('${p.unitString}'), + subtitle: Text('${p.dateFrom} - ${p.dateTo}'), + ); + }); + + Widget _contentNoData = const Text('No Data to show'); + + Widget _contentNotFetched = const Column(children: [ + const Text("Press 'Auth' to get permissions to access health data."), + const Text("Press 'Fetch Dat' to get health data."), + const Text("Press 'Add Data' to add some random health data."), + const Text("Press 'Delete Data' to remove some random health data."), + ], mainAxisAlignment: MainAxisAlignment.center); + + Widget _authorized = const Text('Authorization granted!'); + + Widget _authorizationNotGranted = const Column( + children: [ + const Text('Authorization not given.'), + const Text( + 'For Google Fit please check your OAUTH2 client ID is correct in Google Developer Console.'), + const Text( + 'For Google Health Connect please check if you have added the right permissions and services to the manifest file.'), + const Text('For Apple Health check your permissions in Apple Health.'), + ], + mainAxisAlignment: MainAxisAlignment.center, + ); + + Widget _contentHealthConnectStatus = const Text( + 'No status, click getHealthConnectSdkStatus to get the status.'); + + Widget _dataAdded = const Text('Data points inserted successfully.'); + + Widget _dataDeleted = const Text('Data points deleted successfully.'); + + Widget get _stepsFetched => Text('Total number of steps: $_nofSteps.'); + + Widget _dataNotAdded = + const Text('Failed to add data.\nDo you have permissions to add data?'); + + Widget _dataNotDeleted = const Text('Failed to delete data'); + + Widget get _content => switch (_state) { + AppState.DATA_READY => _contentDataReady, + AppState.DATA_NOT_FETCHED => _contentNotFetched, + AppState.FETCHING_DATA => _contentFetchingData, + AppState.NO_DATA => _contentNoData, + AppState.AUTHORIZED => _authorized, + AppState.AUTH_NOT_GRANTED => _authorizationNotGranted, + AppState.DATA_ADDED => _dataAdded, + AppState.DATA_DELETED => _dataDeleted, + AppState.DATA_NOT_ADDED => _dataNotAdded, + AppState.DATA_NOT_DELETED => _dataNotDeleted, + AppState.STEPS_READY => _stepsFetched, + AppState.HEALTH_CONNECT_STATUS => _contentHealthConnectStatus, + }; } diff --git a/packages/health/example/lib/util.dart b/packages/health/example/lib/util.dart index bef195969..6bfe0c493 100644 --- a/packages/health/example/lib/util.dart +++ b/packages/health/example/lib/util.dart @@ -13,6 +13,7 @@ const List dataTypesIOS = [ HealthDataType.BODY_MASS_INDEX, HealthDataType.BODY_TEMPERATURE, HealthDataType.DIETARY_CARBS_CONSUMED, + HealthDataType.DIETARY_CAFFEINE, HealthDataType.DIETARY_ENERGY_CONSUMED, HealthDataType.DIETARY_FATS_CONSUMED, HealthDataType.DIETARY_PROTEIN_CONSUMED, @@ -87,4 +88,5 @@ const List dataTypesAndroid = [ HealthDataType.RESTING_HEART_RATE, HealthDataType.FLIGHTS_CLIMBED, HealthDataType.NUTRITION, + HealthDataType.TOTAL_CALORIES_BURNED, ]; diff --git a/packages/health/example/pubspec.yaml b/packages/health/example/pubspec.yaml index b6885ba3d..f23767d27 100644 --- a/packages/health/example/pubspec.yaml +++ b/packages/health/example/pubspec.yaml @@ -4,20 +4,21 @@ publish_to: "none" version: 4.5.0 environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=3.0.0" + sdk: ">=3.2.0 <4.0.0" + flutter: ">=3.6.0" dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.2 permission_handler: ^10.2.0 + carp_serializable: ^1.1.0 # polymorphic json serialization + health: + path: ../ dev_dependencies: flutter_test: sdk: flutter - health: - path: ../ flutter: uses-material-design: true diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index d5c00f6a1..a32f0fbf1 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -3,16 +3,18 @@ import HealthKit import UIKit public class SwiftHealthPlugin: NSObject, FlutterPlugin { - + let healthStore = HKHealthStore() var healthDataTypes = [HKSampleType]() + var healthDataQuantityTypes = [HKQuantityType]() var heartRateEventTypes = Set() var headacheType = Set() var allDataTypes = Set() var dataTypesDict: [String: HKSampleType] = [:] + var dataQuantityTypesDict: [String: HKQuantityType] = [:] var unitDict: [String: HKUnit] = [:] var workoutActivityTypeMap: [String: HKWorkoutActivityType] = [:] - + // Health Data Type Keys let ACTIVE_ENERGY_BURNED = "ACTIVE_ENERGY_BURNED" let AUDIOGRAM = "AUDIOGRAM" @@ -28,6 +30,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let DIETARY_ENERGY_CONSUMED = "DIETARY_ENERGY_CONSUMED" let DIETARY_FATS_CONSUMED = "DIETARY_FATS_CONSUMED" let DIETARY_PROTEIN_CONSUMED = "DIETARY_PROTEIN_CONSUMED" + let DIETARY_CAFFEINE = "DIETARY_CAFFEINE" let ELECTRODERMAL_ACTIVITY = "ELECTRODERMAL_ACTIVITY" let FORCED_EXPIRATORY_VOLUME = "FORCED_EXPIRATORY_VOLUME" let HEART_RATE = "HEART_RATE" @@ -45,15 +48,20 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let WALKING_HEART_RATE = "WALKING_HEART_RATE" let WEIGHT = "WEIGHT" let DISTANCE_WALKING_RUNNING = "DISTANCE_WALKING_RUNNING" + let DISTANCE_SWIMMING = "DISTANCE_SWIMMING" + let DISTANCE_CYCLING = "DISTANCE_CYCLING" let FLIGHTS_CLIMBED = "FLIGHTS_CLIMBED" let WATER = "WATER" let MINDFULNESS = "MINDFULNESS" let SLEEP_IN_BED = "SLEEP_IN_BED" let SLEEP_ASLEEP = "SLEEP_ASLEEP" + let SLEEP_ASLEEP_CORE = "SLEEP_ASLEEP_CORE" + let SLEEP_ASLEEP_DEEP = "SLEEP_ASLEEP_DEEP" + let SLEEP_ASLEEP_REM = "SLEEP_ASLEEP_REM" let SLEEP_AWAKE = "SLEEP_AWAKE" let SLEEP_DEEP = "SLEEP_DEEP" let SLEEP_REM = "SLEEP_REM" - + let EXERCISE_TIME = "EXERCISE_TIME" let WORKOUT = "WORKOUT" let HEADACHE_UNSPECIFIED = "HEADACHE_UNSPECIFIED" @@ -63,7 +71,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let HEADACHE_SEVERE = "HEADACHE_SEVERE" let ELECTROCARDIOGRAM = "ELECTROCARDIOGRAM" let NUTRITION = "NUTRITION" - + // Health Unit types // MOLE_UNIT_WITH_MOLAR_MASS, // requires molar mass input - not supported yet // MOLE_UNIT_WITH_PREFIX_MOLAR_MASS, // requires molar mass & prefix input - not supported yet @@ -115,22 +123,22 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let MILLIGRAM_PER_DECILITER = "MILLIGRAM_PER_DECILITER" let UNKNOWN_UNIT = "UNKNOWN_UNIT" let NO_UNIT = "NO_UNIT" - + struct PluginError: Error { let message: String } - + public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( name: "flutter_health", binaryMessenger: registrar.messenger()) let instance = SwiftHealthPlugin() registrar.addMethodCallDelegate(instance, channel: channel) } - + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { // Set up all data types initializeTypes() - + /// Handle checkIfHealthDataAvailable if call.method.elementsEqual("checkIfHealthDataAvailable") { checkIfHealthDataAvailable(call: call, result: result) @@ -138,32 +146,37 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { else if call.method.elementsEqual("requestAuthorization") { try! requestAuthorization(call: call, result: result) } - + /// Handle getData else if call.method.elementsEqual("getData") { getData(call: call, result: result) } - + + /// Handle getIntervalData + else if (call.method.elementsEqual("getIntervalData")){ + getIntervalData(call: call, result: result) + } + /// Handle getTotalStepsInInterval else if call.method.elementsEqual("getTotalStepsInInterval") { getTotalStepsInInterval(call: call, result: result) } - + /// Handle writeData else if call.method.elementsEqual("writeData") { try! writeData(call: call, result: result) } - + /// Handle writeAudiogram else if call.method.elementsEqual("writeAudiogram") { try! writeAudiogram(call: call, result: result) } - + /// Handle writeBloodPressure else if call.method.elementsEqual("writeBloodPressure") { try! writeBloodPressure(call: call, result: result) } - + /// Handle writeMeal else if (call.method.elementsEqual("writeMeal")){ try! writeMeal(call: call, result: result) @@ -178,23 +191,29 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { else if call.method.elementsEqual("writeWorkoutData") { try! writeWorkoutData(call: call, result: result) } - + /// Handle hasPermission else if call.method.elementsEqual("hasPermissions") { try! hasPermissions(call: call, result: result) } - + /// Handle delete data else if call.method.elementsEqual("delete") { try! delete(call: call, result: result) } - + + /// Disconnect + else if (call.method.elementsEqual("disconnect")){ + // Do nothing. + result(true) + } + } - + func checkIfHealthDataAvailable(call: FlutterMethodCall, result: @escaping FlutterResult) { result(HKHealthStore.isHealthDataAvailable()) } - + func hasPermissions(call: FlutterMethodCall, result: @escaping FlutterResult) throws { let arguments = call.arguments as? NSDictionary guard var types = arguments?["types"] as? [String], @@ -203,12 +222,12 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { else { throw PluginError(message: "Invalid Arguments!") } - + if let nutritionIndex = types.firstIndex(of: NUTRITION) { types.remove(at: nutritionIndex) let nutritionPermission = permissions[nutritionIndex] permissions.remove(at: nutritionIndex) - + types.append(DIETARY_ENERGY_CONSUMED) permissions.append(nutritionPermission) types.append(DIETARY_CARBS_CONSUMED) @@ -217,8 +236,10 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { permissions.append(nutritionPermission) types.append(DIETARY_FATS_CONSUMED) permissions.append(nutritionPermission) + types.append(DIETARY_CAFFEINE) + permissions.append(nutritionPermission) } - + for (index, type) in types.enumerated() { let sampleType = dataTypeLookUp(key: type) let success = hasPermission(type: sampleType, access: permissions[index]) @@ -227,12 +248,12 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { return } } - + result(true) } - + func hasPermission(type: HKSampleType, access: Int) -> Bool? { - + if #available(iOS 13.0, *) { let status = healthStore.authorizationStatus(for: type) switch access { @@ -247,7 +268,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { return nil } } - + func requestAuthorization(call: FlutterMethodCall, result: @escaping FlutterResult) throws { guard let arguments = call.arguments as? NSDictionary, let types = arguments["types"] as? [String], @@ -256,7 +277,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { else { throw PluginError(message: "Invalid Arguments!") } - + var typesToRead = Set() var typesToWrite = Set() for (index, key) in types.enumerated() { @@ -265,11 +286,12 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let carbsType = dataTypeLookUp(key: DIETARY_CARBS_CONSUMED) let proteinType = dataTypeLookUp(key: DIETARY_PROTEIN_CONSUMED) let fatType = dataTypeLookUp(key: DIETARY_FATS_CONSUMED) - + let caffeineType = dataTypeLookUp(key: DIETARY_CAFFEINE) typesToWrite.insert(caloriesType); typesToWrite.insert(carbsType); typesToWrite.insert(proteinType); typesToWrite.insert(fatType); + typesToWrite.insert(caffeineType); } else { let dataType = dataTypeLookUp(key: key) let access = permissions[index] @@ -284,7 +306,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } } } - + if #available(iOS 13.0, *) { healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { (success, error) in @@ -296,7 +318,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { result(false) // Handle the error here. } } - + func writeData(call: FlutterMethodCall, result: @escaping FlutterResult) throws { guard let arguments = call.arguments as? NSDictionary, let value = (arguments["value"] as? Double), @@ -307,12 +329,12 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { else { throw PluginError(message: "Invalid Arguments") } - + let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) - + let sample: HKObject - + if dataTypeLookUp(key: type).isKind(of: HKCategoryType.self) { sample = HKCategorySample( type: dataTypeLookUp(key: type) as! HKCategoryType, value: Int(value), start: dateFrom, @@ -323,7 +345,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { type: dataTypeLookUp(key: type) as! HKQuantityType, quantity: quantity, start: dateFrom, end: dateTo) } - + HKHealthStore().save( sample, withCompletion: { (success, error) in @@ -335,7 +357,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } }) } - + func writeAudiogram(call: FlutterMethodCall, result: @escaping FlutterResult) throws { guard let arguments = call.arguments as? NSDictionary, let frequencies = (arguments["frequencies"] as? [Double]), @@ -346,12 +368,12 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { else { throw PluginError(message: "Invalid Arguments") } - + let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) - + var sensitivityPoints = [HKAudiogramSensitivityPoint]() - + for index in 0...frequencies.count - 1 { let frequency = HKQuantity(unit: HKUnit.hertz(), doubleValue: frequencies[index]) let dbUnit = HKUnit.decibelHearingLevel() @@ -361,23 +383,23 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { frequency: frequency, leftEarSensitivity: left, rightEarSensitivity: right) sensitivityPoints.append(sensitivityPoint) } - + let audiogram: HKAudiogramSample let metadataReceived = (arguments["metadata"] as? [String: Any]?) - + if (metadataReceived) != nil { guard let deviceName = metadataReceived?!["HKDeviceName"] as? String else { return } guard let externalUUID = metadataReceived?!["HKExternalUUID"] as? String else { return } - + audiogram = HKAudiogramSample( sensitivityPoints: sensitivityPoints, start: dateFrom, end: dateTo, metadata: [HKMetadataKeyDeviceName: deviceName, HKMetadataKeyExternalUUID: externalUUID]) - + } else { audiogram = HKAudiogramSample( sensitivityPoints: sensitivityPoints, start: dateFrom, end: dateTo, metadata: nil) } - + HKHealthStore().save( audiogram, withCompletion: { (success, error) in @@ -389,7 +411,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } }) } - + func writeBloodPressure(call: FlutterMethodCall, result: @escaping FlutterResult) throws { guard let arguments = call.arguments as? NSDictionary, let systolic = (arguments["systolic"] as? Double), @@ -401,7 +423,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) - + let systolic_sample = HKQuantitySample( type: HKSampleType.quantityType(forIdentifier: .bloodPressureSystolic)!, quantity: HKQuantity(unit: HKUnit.millimeterOfMercury(), doubleValue: systolic), @@ -410,9 +432,12 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { type: HKSampleType.quantityType(forIdentifier: .bloodPressureDiastolic)!, quantity: HKQuantity(unit: HKUnit.millimeterOfMercury(), doubleValue: diastolic), start: dateFrom, end: dateTo) - + let bpCorrelationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure)! + let bpCorrelation = Set(arrayLiteral: systolic_sample, diastolic_sample) + let blood_pressure_sample = HKCorrelation(type: bpCorrelationType , start: dateFrom, end: dateTo, objects: bpCorrelation) + HKHealthStore().save( - [systolic_sample, diastolic_sample], + [blood_pressure_sample], withCompletion: { (success, error) in if let err = error { print("Error Saving Blood Pressure Sample: \(err.localizedDescription)") @@ -422,7 +447,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } }) } - + func writeMeal(call: FlutterMethodCall, result: @escaping FlutterResult) throws { guard let arguments = call.arguments as? NSDictionary, let startTime = (arguments["startTime"] as? NSNumber), @@ -432,6 +457,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let protein = (arguments["protein"] as? Double?) ?? 0, let fat = (arguments["fatTotal"] as? Double?) ?? 0, let name = (arguments["name"] as? String?), + let caffeine = (arguments["caffeine"] as? Double?) ?? 0, let mealType = (arguments["mealType"] as? String?) else { throw PluginError(message: "Invalid Arguments") @@ -439,38 +465,44 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) - + var mealTypeString = mealType ?? "UNKNOWN" var metadata = ["HKFoodMeal": "\(mealTypeString)"] - + if(name != nil) { metadata[HKMetadataKeyFoodType] = "\(name!)" } - + var nutrition = Set() - - let caloriesSample = HKQuantitySample(type: HKSampleType.quantityType(forIdentifier: .dietaryEnergyConsumed)!, quantity: HKQuantity(unit: HKUnit.kilocalorie(), doubleValue: calories), start: dateFrom, end: dateTo, metadata: metadata) - nutrition.insert(caloriesSample) - + + if(calories > 0) { + let caloriesSample = HKQuantitySample(type: HKSampleType.quantityType(forIdentifier: .dietaryEnergyConsumed)!, quantity: HKQuantity(unit: HKUnit.kilocalorie(), doubleValue: calories), start: dateFrom, end: dateTo, metadata: metadata) + nutrition.insert(caloriesSample) + } + if(carbs > 0) { let carbsSample = HKQuantitySample(type: HKSampleType.quantityType(forIdentifier: .dietaryCarbohydrates)!, quantity: HKQuantity(unit: HKUnit.gram(), doubleValue: carbs), start: dateFrom, end: dateTo, metadata: metadata) nutrition.insert(carbsSample) } - + if(protein > 0) { let proteinSample = HKQuantitySample(type: HKSampleType.quantityType(forIdentifier: .dietaryProtein)!, quantity: HKQuantity(unit: HKUnit.gram(), doubleValue: protein), start: dateFrom, end: dateTo, metadata: metadata) nutrition.insert(proteinSample) } - + if(fat > 0) { let fatSample = HKQuantitySample(type: HKSampleType.quantityType(forIdentifier: .dietaryFatTotal)!, quantity: HKQuantity(unit: HKUnit.gram(), doubleValue: fat), start: dateFrom, end: dateTo, metadata: metadata) nutrition.insert(fatSample) } + + if(caffeine > 0) { + let caffeineSample = HKQuantitySample(type: HKSampleType.quantityType(forIdentifier: .dietaryCaffeine)!, quantity: HKQuantity(unit: HKUnit.gram(), doubleValue: caffeine), start: dateFrom, end: dateTo, metadata: metadata) + nutrition.insert(caffeineSample) + } if #available(iOS 15.0, *){ - let type = HKCorrelationType.correlationType(forIdentifier: HKCorrelationTypeIdentifier.food)! - let meal = HKCorrelation(type: type, start: dateFrom, end: dateTo, objects: nutrition, metadata: metadata) - + let meal = HKCorrelation.init(type: HKCorrelationType.init(HKCorrelationTypeIdentifier.food), start: dateFrom, end: dateTo, objects: nutrition, metadata: metadata) + HKHealthStore().save(meal, withCompletion: { (success, error) in if let err = error { print("Error Saving Meal Sample: \(err.localizedDescription)") @@ -520,10 +552,10 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { else { throw PluginError(message: "Invalid Arguments - activityType, startTime or endTime invalid") } - + var totalEnergyBurned: HKQuantity? var totalDistance: HKQuantity? = nil - + // Handle optional arguments if let teb = (arguments["totalEnergyBurned"] as? Double) { totalEnergyBurned = HKQuantity( @@ -533,17 +565,17 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { totalDistance = HKQuantity( unit: unitDict[(arguments["totalDistanceUnit"] as! String)]!, doubleValue: td) } - + let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) - + var workout: HKWorkout - + workout = HKWorkout( activityType: ac, start: dateFrom, end: dateTo, duration: dateTo.timeIntervalSince(dateFrom), totalEnergyBurned: totalEnergyBurned ?? nil, totalDistance: totalDistance ?? nil, metadata: nil) - + HKHealthStore().save( workout, withCompletion: { (success, error) in @@ -555,33 +587,33 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } }) } - + func delete(call: FlutterMethodCall, result: @escaping FlutterResult) { let arguments = call.arguments as? NSDictionary let dataTypeKey = (arguments?["dataTypeKey"] as? String)! let startTime = (arguments?["startTime"] as? NSNumber) ?? 0 let endTime = (arguments?["endTime"] as? NSNumber) ?? 0 - + let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) - + let dataType = dataTypeLookUp(key: dataTypeKey) - + let predicate = HKQuery.predicateForSamples( withStart: dateFrom, end: dateTo, options: .strictStartDate) let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) - + let deleteQuery = HKSampleQuery( sampleType: dataType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor] ) { [self] x, samplesOrNil, error in - + guard let samplesOrNil = samplesOrNil, error == nil else { // Handle the error if necessary print("Error deleting \(dataType)") return } - + // Delete the retrieved objects from the HealthKit store HKHealthStore().delete(samplesOrNil) { (success, error) in if let err = error { @@ -592,10 +624,10 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } } } - + HKHealthStore().execute(deleteQuery) } - + func getData(call: FlutterMethodCall, result: @escaping FlutterResult) { let arguments = call.arguments as? NSDictionary let dataTypeKey = (arguments?["dataTypeKey"] as? String)! @@ -603,27 +635,32 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let startTime = (arguments?["startTime"] as? NSNumber) ?? 0 let endTime = (arguments?["endTime"] as? NSNumber) ?? 0 let limit = (arguments?["limit"] as? Int) ?? HKObjectQueryNoLimit - + let includeManualEntry = (arguments?["includeManualEntry"] as? Bool) ?? true + // Convert dates from milliseconds to Date() let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) - + let dataType = dataTypeLookUp(key: dataTypeKey) var unit: HKUnit? if let dataUnitKey = dataUnitKey { unit = unitDict[dataUnitKey] } - - let predicate = HKQuery.predicateForSamples( + + var predicate = HKQuery.predicateForSamples( withStart: dateFrom, end: dateTo, options: .strictStartDate) + if (!includeManualEntry) { + let manualPredicate = NSPredicate(format: "metadata.%K != YES", HKMetadataKeyWasUserEntered) + predicate = NSCompoundPredicate(type: .and, subpredicates: [predicate, manualPredicate]) + } let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) - + let query = HKSampleQuery( sampleType: dataType, predicate: predicate, limit: limit, sortDescriptors: [sortDescriptor] ) { [self] x, samplesOrNil, error in - + switch samplesOrNil { case let (samples as [HKQuantitySample]) as Any: let dictionaries = samples.map { sample -> NSDictionary in @@ -634,18 +671,28 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { "date_to": Int(sample.endDate.timeIntervalSince1970 * 1000), "source_id": sample.sourceRevision.source.bundleIdentifier, "source_name": sample.sourceRevision.source.name, + "is_manual_entry": sample.metadata?[HKMetadataKeyWasUserEntered] != nil, "metadata": dataTypeKey == INSULIN_DELIVERY ? sample.metadata : nil ] } DispatchQueue.main.async { result(dictionaries) } - + case var (samplesCategory as [HKCategorySample]) as Any: - + if dataTypeKey == self.SLEEP_IN_BED { samplesCategory = samplesCategory.filter { $0.value == 0 } } + if dataTypeKey == self.SLEEP_ASLEEP_CORE { + samplesCategory = samplesCategory.filter { $0.value == 3 } + } + if dataTypeKey == self.SLEEP_ASLEEP_DEEP { + samplesCategory = samplesCategory.filter { $0.value == 4 } + } + if dataTypeKey == self.SLEEP_ASLEEP_REM { + samplesCategory = samplesCategory.filter { $0.value == 5 } + } if dataTypeKey == self.SLEEP_AWAKE { samplesCategory = samplesCategory.filter { $0.value == 2 } } @@ -681,14 +728,15 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { "date_to": Int(sample.endDate.timeIntervalSince1970 * 1000), "source_id": sample.sourceRevision.source.bundleIdentifier, "source_name": sample.sourceRevision.source.name, + "is_manual_entry": sample.metadata?[HKMetadataKeyWasUserEntered] != nil ] } DispatchQueue.main.async { result(categories) } - + case let (samplesWorkout as [HKWorkout]) as Any: - + let dictionaries = samplesWorkout.map { sample -> NSDictionary in return [ "uuid": "\(sample.uuid)", @@ -703,13 +751,17 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { "date_to": Int(sample.endDate.timeIntervalSince1970 * 1000), "source_id": sample.sourceRevision.source.bundleIdentifier, "source_name": sample.sourceRevision.source.name, + "is_manual_entry": sample.metadata?[HKMetadataKeyWasUserEntered] != nil, + "workout_type": self.getWorkoutType(type: sample.workoutActivityType), + "total_distance": sample.totalDistance != nil ? Int(sample.totalDistance!.doubleValue(for: HKUnit.meter())) : 0, + "total_energy_burned": sample.totalEnergyBurned != nil ? Int(sample.totalEnergyBurned!.doubleValue(for: HKUnit.kilocalorie())) : 0 ] } - + DispatchQueue.main.async { result(dictionaries) } - + case let (samplesAudiogram as [HKAudiogramSample]) as Any: let dictionaries = samplesAudiogram.map { sample -> NSDictionary in var frequencies = [Double]() @@ -736,15 +788,15 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { DispatchQueue.main.async { result(dictionaries) } - + case let (nutritionSample as [HKCorrelation]) as Any: - + //let samples = nutritionSample[0].objects(for: HKObjectType.quantityType(forIdentifier: .dietaryEnergyConsumed)!) var calories = 0.0 var fat = 0.0 var carbs = 0.0 var protein = 0.0 - + let name = nutritionSample[0].metadata?[HKMetadataKeyFoodType] as! String let mealType = nutritionSample[0].metadata?["HKFoodMeal"] let samples = nutritionSample[0].objects @@ -764,8 +816,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } } } - - + + let dictionaries = nutritionSample.map { sample -> NSDictionary in return [ "uuid": "\(sample.uuid)", @@ -784,7 +836,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { DispatchQueue.main.async { result(dictionaries) } - + default: if #available(iOS 14.0, *), let ecgSamples = samplesOrNil as? [HKElectrocardiogram] { let dictionaries = ecgSamples.map(fetchEcgMeasurements) @@ -799,10 +851,10 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } } } - + HKHealthStore().execute(query) } - + @available(iOS 14.0, *) private func fetchEcgMeasurements(_ sample: HKElectrocardiogram) -> NSDictionary { let semaphore = DispatchSemaphore(value: 0) @@ -836,66 +888,153 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { "source_name": sample.sourceRevision.source.name, ] } - + + func getIntervalData(call: FlutterMethodCall, result: @escaping FlutterResult) { + let arguments = call.arguments as? NSDictionary + let dataTypeKey = (arguments?["dataTypeKey"] as? String) ?? "DEFAULT" + let dataUnitKey = (arguments?["dataUnitKey"] as? String) + let startDate = (arguments?["startTime"] as? NSNumber) ?? 0 + let endDate = (arguments?["endTime"] as? NSNumber) ?? 0 + let intervalInSecond = (arguments?["interval"] as? Int) ?? 1 + let includeManualEntry = (arguments?["includeManualEntry"] as? Bool) ?? true + + // Set interval in seconds. + var interval = DateComponents() + interval.second = intervalInSecond + + // Convert dates from milliseconds to Date() + let dateFrom = Date(timeIntervalSince1970: startDate.doubleValue / 1000) + let dateTo = Date(timeIntervalSince1970: endDate.doubleValue / 1000) + + let quantityType: HKQuantityType! = dataQuantityTypesDict[dataTypeKey] + var predicate = HKQuery.predicateForSamples(withStart: dateFrom, end: dateTo, options: []) + if (!includeManualEntry) { + let manualPredicate = NSPredicate(format: "metadata.%K != YES", HKMetadataKeyWasUserEntered) + predicate = NSCompoundPredicate(type: .and, subpredicates: [predicate, manualPredicate]) + } + + let query = HKStatisticsCollectionQuery(quantityType: quantityType, quantitySamplePredicate: predicate, options: [.cumulativeSum, .separateBySource], anchorDate: dateFrom, intervalComponents: interval) + + query.initialResultsHandler = { + [weak self] _, statisticCollectionOrNil, error in + guard let self = self else { + // Handle the case where self became nil. + print("Self is nil") + DispatchQueue.main.async { + result(nil) + } + return + } + + // Error detected. + if let error = error { + print("Query error: \(error.localizedDescription)") + DispatchQueue.main.async { + result(nil) + } + return + } + + guard let collection = statisticCollectionOrNil as? HKStatisticsCollection else { + print("Unexpected result from query") + DispatchQueue.main.async { + result(nil) + } + return + } + + var dictionaries = [[String: Any]]() + collection.enumerateStatistics(from: dateFrom, to: dateTo) { + [weak self] statisticData, _ in + guard let self = self else { + // Handle the case where self became nil. + print("Self is nil during enumeration") + return + } + + do { + if let quantity = statisticData.sumQuantity(), + let dataUnitKey = dataUnitKey, + let unit = self.unitDict[dataUnitKey] { + let dict = [ + "value": quantity.doubleValue(for: unit), + "date_from": Int(statisticData.startDate.timeIntervalSince1970 * 1000), + "date_to": Int(statisticData.endDate.timeIntervalSince1970 * 1000), + "source_id": statisticData.sources?.first?.bundleIdentifier ?? "", + "source_name": statisticData.sources?.first?.name ?? "" + ] + dictionaries.append(dict) + } + } catch { + print("Error during collection.enumeration: \(error)") + } + } + DispatchQueue.main.async { + result(dictionaries) + } + } + HKHealthStore().execute(query) + } + func getTotalStepsInInterval(call: FlutterMethodCall, result: @escaping FlutterResult) { let arguments = call.arguments as? NSDictionary let startTime = (arguments?["startTime"] as? NSNumber) ?? 0 let endTime = (arguments?["endTime"] as? NSNumber) ?? 0 - + // Convert dates from milliseconds to Date() let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) - + let sampleType = HKQuantityType.quantityType(forIdentifier: .stepCount)! let predicate = HKQuery.predicateForSamples( withStart: dateFrom, end: dateTo, options: .strictStartDate) - + let query = HKStatisticsQuery( quantityType: sampleType, quantitySamplePredicate: predicate, options: .cumulativeSum ) { query, queryResult, error in - + guard let queryResult = queryResult else { let error = error! as NSError print("Error getting total steps in interval \(error.localizedDescription)") - + DispatchQueue.main.async { result(nil) } return } - + var steps = 0.0 - + if let quantity = queryResult.sumQuantity() { let unit = HKUnit.count() steps = quantity.doubleValue(for: unit) } - + let totalSteps = Int(steps) DispatchQueue.main.async { result(totalSteps) } } - + HKHealthStore().execute(query) } - + func unitLookUp(key: String) -> HKUnit { guard let unit = unitDict[key] else { return HKUnit.count() } return unit } - + func dataTypeLookUp(key: String) -> HKSampleType { guard let dataType_ = dataTypesDict[key] else { return HKSampleType.quantityType(forIdentifier: .bodyMass)! } return dataType_ } - + func initializeTypes() { // Initialize units unitDict[GRAM] = HKUnit.gram() @@ -944,7 +1083,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { unitDict[MILLIGRAM_PER_DECILITER] = HKUnit.init(from: "mg/dL") unitDict[UNKNOWN_UNIT] = HKUnit.init(from: "") unitDict[NO_UNIT] = HKUnit.init(from: "") - + // Initialize workout types workoutActivityTypeMap["ARCHERY"] = .archery workoutActivityTypeMap["BOWLING"] = .bowling @@ -1027,7 +1166,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { workoutActivityTypeMap["TAI_CHI"] = .taiChi workoutActivityTypeMap["WRESTLING"] = .wrestling workoutActivityTypeMap["OTHER"] = .other - + // Set up iOS 13 specific types (ordinary health data types) if #available(iOS 13.0, *) { dataTypesDict[ACTIVE_ENERGY_BURNED] = HKSampleType.quantityType( @@ -1040,7 +1179,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { dataTypesDict[RESPIRATORY_RATE] = HKSampleType.quantityType(forIdentifier: .respiratoryRate)! dataTypesDict[PERIPHERAL_PERFUSION_INDEX] = HKSampleType.quantityType( forIdentifier: .peripheralPerfusionIndex)! - + dataTypesDict[BLOOD_PRESSURE_DIASTOLIC] = HKSampleType.quantityType( forIdentifier: .bloodPressureDiastolic)! dataTypesDict[BLOOD_PRESSURE_SYSTOLIC] = HKSampleType.quantityType( @@ -1053,6 +1192,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { forIdentifier: .dietaryCarbohydrates)! dataTypesDict[DIETARY_ENERGY_CONSUMED] = HKSampleType.quantityType( forIdentifier: .dietaryEnergyConsumed)! + dataTypesDict[DIETARY_CAFFEINE] = HKSampleType.quantityType( + forIdentifier: .dietaryCaffeine)! dataTypesDict[DIETARY_FATS_CONSUMED] = HKSampleType.quantityType( forIdentifier: .dietaryFatTotal)! dataTypesDict[DIETARY_PROTEIN_CONSUMED] = HKSampleType.quantityType( @@ -1076,22 +1217,62 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { dataTypesDict[WEIGHT] = HKSampleType.quantityType(forIdentifier: .bodyMass)! dataTypesDict[DISTANCE_WALKING_RUNNING] = HKSampleType.quantityType( forIdentifier: .distanceWalkingRunning)! + dataTypesDict[DISTANCE_SWIMMING] = HKSampleType.quantityType(forIdentifier: .distanceSwimming)! + dataTypesDict[DISTANCE_CYCLING] = HKSampleType.quantityType(forIdentifier: .distanceCycling)! dataTypesDict[FLIGHTS_CLIMBED] = HKSampleType.quantityType(forIdentifier: .flightsClimbed)! dataTypesDict[WATER] = HKSampleType.quantityType(forIdentifier: .dietaryWater)! dataTypesDict[MINDFULNESS] = HKSampleType.categoryType(forIdentifier: .mindfulSession)! dataTypesDict[SLEEP_IN_BED] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! dataTypesDict[SLEEP_ASLEEP] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! + dataTypesDict[SLEEP_ASLEEP_CORE] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! + dataTypesDict[SLEEP_ASLEEP_DEEP] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! + dataTypesDict[SLEEP_ASLEEP_REM] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! dataTypesDict[SLEEP_AWAKE] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! dataTypesDict[SLEEP_DEEP] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! dataTypesDict[SLEEP_REM] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! - + dataTypesDict[EXERCISE_TIME] = HKSampleType.quantityType(forIdentifier: .appleExerciseTime)! dataTypesDict[WORKOUT] = HKSampleType.workoutType() dataTypesDict[NUTRITION] = HKSampleType.correlationType( forIdentifier: .food)! - + healthDataTypes = Array(dataTypesDict.values) } + + // Set up iOS 11 specific types (ordinary health data quantity types) + if #available(iOS 11.0, *) { + dataQuantityTypesDict[ACTIVE_ENERGY_BURNED] = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)! + dataQuantityTypesDict[BASAL_ENERGY_BURNED] = HKQuantityType.quantityType(forIdentifier: .basalEnergyBurned)! + dataQuantityTypesDict[BLOOD_GLUCOSE] = HKQuantityType.quantityType(forIdentifier: .bloodGlucose)! + dataQuantityTypesDict[BLOOD_OXYGEN] = HKQuantityType.quantityType(forIdentifier: .oxygenSaturation)! + dataQuantityTypesDict[BLOOD_PRESSURE_DIASTOLIC] = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic)! + dataQuantityTypesDict[BLOOD_PRESSURE_SYSTOLIC] = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic)! + dataQuantityTypesDict[BODY_FAT_PERCENTAGE] = HKQuantityType.quantityType(forIdentifier: .bodyFatPercentage)! + dataQuantityTypesDict[BODY_MASS_INDEX] = HKQuantityType.quantityType(forIdentifier: .bodyMassIndex)! + dataQuantityTypesDict[BODY_TEMPERATURE] = HKQuantityType.quantityType(forIdentifier: .bodyTemperature)! + dataQuantityTypesDict[DIETARY_CARBS_CONSUMED] = HKQuantityType.quantityType(forIdentifier: .dietaryCarbohydrates)! + dataQuantityTypesDict[DIETARY_ENERGY_CONSUMED] = HKQuantityType.quantityType(forIdentifier: .dietaryEnergyConsumed)! + dataQuantityTypesDict[DIETARY_FATS_CONSUMED] = HKQuantityType.quantityType(forIdentifier: .dietaryFatTotal)! + dataQuantityTypesDict[DIETARY_PROTEIN_CONSUMED] = HKQuantityType.quantityType(forIdentifier: .dietaryProtein)! + dataQuantityTypesDict[ELECTRODERMAL_ACTIVITY] = HKQuantityType.quantityType(forIdentifier: .electrodermalActivity)! + dataQuantityTypesDict[FORCED_EXPIRATORY_VOLUME] = HKQuantityType.quantityType(forIdentifier: .forcedExpiratoryVolume1)! + dataQuantityTypesDict[HEART_RATE] = HKQuantityType.quantityType(forIdentifier: .heartRate)! + dataQuantityTypesDict[HEART_RATE_VARIABILITY_SDNN] = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)! + dataQuantityTypesDict[HEIGHT] = HKQuantityType.quantityType(forIdentifier: .height)! + dataQuantityTypesDict[RESTING_HEART_RATE] = HKQuantityType.quantityType(forIdentifier: .restingHeartRate)! + dataQuantityTypesDict[STEPS] = HKQuantityType.quantityType(forIdentifier: .stepCount)! + dataQuantityTypesDict[WAIST_CIRCUMFERENCE] = HKQuantityType.quantityType(forIdentifier: .waistCircumference)! + dataQuantityTypesDict[WALKING_HEART_RATE] = HKQuantityType.quantityType(forIdentifier: .walkingHeartRateAverage)! + dataQuantityTypesDict[WEIGHT] = HKQuantityType.quantityType(forIdentifier: .bodyMass)! + dataQuantityTypesDict[DISTANCE_WALKING_RUNNING] = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)! + dataQuantityTypesDict[DISTANCE_SWIMMING] = HKQuantityType.quantityType(forIdentifier: .distanceSwimming)! + dataQuantityTypesDict[DISTANCE_CYCLING] = HKQuantityType.quantityType(forIdentifier: .distanceCycling)! + dataQuantityTypesDict[FLIGHTS_CLIMBED] = HKQuantityType.quantityType(forIdentifier: .flightsClimbed)! + dataQuantityTypesDict[WATER] = HKQuantityType.quantityType(forIdentifier: .dietaryWater)! + + healthDataQuantityTypes = Array(dataQuantityTypesDict.values) + } + // Set up heart rate data types specific to the apple watch, requires iOS 12 if #available(iOS 12.2, *) { dataTypesDict[HIGH_HEART_RATE_EVENT] = HKSampleType.categoryType( @@ -1100,40 +1281,195 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { forIdentifier: .lowHeartRateEvent)! dataTypesDict[IRREGULAR_HEART_RATE_EVENT] = HKSampleType.categoryType( forIdentifier: .irregularHeartRhythmEvent)! - + heartRateEventTypes = Set([ HKSampleType.categoryType(forIdentifier: .highHeartRateEvent)!, HKSampleType.categoryType(forIdentifier: .lowHeartRateEvent)!, HKSampleType.categoryType(forIdentifier: .irregularHeartRhythmEvent)!, ]) } - + if #available(iOS 13.6, *) { dataTypesDict[HEADACHE_UNSPECIFIED] = HKSampleType.categoryType(forIdentifier: .headache)! dataTypesDict[HEADACHE_NOT_PRESENT] = HKSampleType.categoryType(forIdentifier: .headache)! dataTypesDict[HEADACHE_MILD] = HKSampleType.categoryType(forIdentifier: .headache)! dataTypesDict[HEADACHE_MODERATE] = HKSampleType.categoryType(forIdentifier: .headache)! dataTypesDict[HEADACHE_SEVERE] = HKSampleType.categoryType(forIdentifier: .headache)! - + headacheType = Set([ HKSampleType.categoryType(forIdentifier: .headache)! ]) } - + if #available(iOS 14.0, *) { dataTypesDict[ELECTROCARDIOGRAM] = HKSampleType.electrocardiogramType() - + unitDict[VOLT] = HKUnit.volt() unitDict[INCHES_OF_MERCURY] = HKUnit.inchesOfMercury() - + workoutActivityTypeMap["CARDIO_DANCE"] = HKWorkoutActivityType.cardioDance workoutActivityTypeMap["SOCIAL_DANCE"] = HKWorkoutActivityType.socialDance workoutActivityTypeMap["PICKLEBALL"] = HKWorkoutActivityType.pickleball workoutActivityTypeMap["COOLDOWN"] = HKWorkoutActivityType.cooldown } - + // Concatenate heart events, headache and health data types (both may be empty) allDataTypes = Set(heartRateEventTypes + healthDataTypes) allDataTypes = allDataTypes.union(headacheType) } + + func getWorkoutType(type: HKWorkoutActivityType) -> String { + switch type { + case .americanFootball: + return "americanFootball" + case .archery: + return "archery" + case .australianFootball: + return "australianFootball" + case .badminton: + return "badminton" + case .baseball: + return "baseball" + case .basketball: + return "basketball" + case .bowling: + return "bowling" + case .boxing: + return "boxing" + case .climbing: + return "climbing" + case .cricket: + return "cricket" + case .crossTraining: + return "crossTraining" + case .curling: + return "curling" + case .cycling: + return "cycling" + case .dance: + return "dance" + case .danceInspiredTraining: + return "danceInspiredTraining" + case .elliptical: + return "elliptical" + case .equestrianSports: + return "equestrianSports" + case .fencing: + return "fencing" + case .fishing: + return "fishing" + case .functionalStrengthTraining: + return "functionalStrengthTraining" + case .golf: + return "golf" + case .gymnastics: + return "gymnastics" + case .handball: + return "handball" + case .hiking: + return "hiking" + case .hockey: + return "hockey" + case .hunting: + return "hunting" + case .lacrosse: + return "lacrosse" + case .martialArts: + return "martialArts" + case .mindAndBody: + return "mindAndBody" + case .mixedMetabolicCardioTraining: + return "mixedMetabolicCardioTraining" + case .paddleSports: + return "paddleSports" + case .play: + return "play" + case .preparationAndRecovery: + return "preparationAndRecovery" + case .racquetball: + return "racquetball" + case .rowing: + return "rowing" + case .rugby: + return "rugby" + case .running: + return "running" + case .sailing: + return "sailing" + case .skatingSports: + return "skatingSports" + case .snowSports: + return "snowSports" + case .soccer: + return "soccer" + case .softball: + return "softball" + case .squash: + return "squash" + case .stairClimbing: + return "stairClimbing" + case .surfingSports: + return "surfingSports" + case .swimming: + return "swimming" + case .tableTennis: + return "tableTennis" + case .tennis: + return "tennis" + case .trackAndField: + return "trackAndField" + case .traditionalStrengthTraining: + return "traditionalStrengthTraining" + case .volleyball: + return "volleyball" + case .walking: + return "walking" + case .waterFitness: + return "waterFitness" + case .waterPolo: + return "waterPolo" + case .waterSports: + return "waterSports" + case .wrestling: + return "wrestling" + case .yoga: + return "yoga" + case .barre: + return "barre" + case .coreTraining: + return "coreTraining" + case .crossCountrySkiing: + return "crossCountrySkiing" + case .downhillSkiing: + return "downhillSkiing" + case .flexibility: + return "flexibility" + case .highIntensityIntervalTraining: + return "highIntensityIntervalTraining" + case .jumpRope: + return "jumpRope" + case .kickboxing: + return "kickboxing" + case .pilates: + return "pilates" + case .snowboarding: + return "snowboarding" + case .stairs: + return "stairs" + case .stepTraining: + return "stepTraining" + case .wheelchairWalkPace: + return "wheelchairWalkPace" + case .wheelchairRunPace: + return "wheelchairRunPace" + case .taiChi: + return "taiChi" + case .mixedCardio: + return "mixedCardio" + case .handCycling: + return "handCycling" + default: + return "other" + } + } } diff --git a/packages/health/lib/health.dart b/packages/health/lib/health.dart index af97b7abd..1c960d54f 100644 --- a/packages/health/lib/health.dart +++ b/packages/health/lib/health.dart @@ -4,12 +4,18 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io' show Platform; +import 'package:carp_serializable/carp_serializable.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -part 'src/data_types.dart'; +part 'src/heath_data_types.dart'; part 'src/functions.dart'; part 'src/health_data_point.dart'; part 'src/health_value_types.dart'; -part 'src/health_factory.dart'; +part 'src/health_plugin.dart'; +part 'src/workout_summary.dart'; + +part 'health.g.dart'; +part 'health.json.dart'; diff --git a/packages/health/lib/health.g.dart b/packages/health/lib/health.g.dart new file mode 100644 index 000000000..43941092d --- /dev/null +++ b/packages/health/lib/health.g.dart @@ -0,0 +1,579 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'health.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +HealthDataPoint _$HealthDataPointFromJson(Map json) => + HealthDataPoint( + value: HealthValue.fromJson(json['value'] as Map), + type: $enumDecode(_$HealthDataTypeEnumMap, json['type']), + unit: $enumDecode(_$HealthDataUnitEnumMap, json['unit']), + dateFrom: DateTime.parse(json['date_from'] as String), + dateTo: DateTime.parse(json['date_to'] as String), + sourcePlatform: + $enumDecode(_$HealthPlatformTypeEnumMap, json['source_platform']), + sourceDeviceId: json['source_device_id'] as String, + sourceId: json['source_id'] as String, + sourceName: json['source_name'] as String, + isManualEntry: json['is_manual_entry'] as bool? ?? false, + workoutSummary: json['workout_summary'] == null + ? null + : WorkoutSummary.fromJson( + json['workout_summary'] as Map), + metadata: json['metadata'] as Map?, + ); + +Map _$HealthDataPointToJson(HealthDataPoint instance) { + final val = { + 'value': instance.value, + 'type': _$HealthDataTypeEnumMap[instance.type]!, + 'unit': _$HealthDataUnitEnumMap[instance.unit]!, + 'date_from': instance.dateFrom.toIso8601String(), + 'date_to': instance.dateTo.toIso8601String(), + 'source_platform': _$HealthPlatformTypeEnumMap[instance.sourcePlatform]!, + 'source_device_id': instance.sourceDeviceId, + 'source_id': instance.sourceId, + 'source_name': instance.sourceName, + 'is_manual_entry': instance.isManualEntry, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('workout_summary', instance.workoutSummary); + writeNotNull('metadata', instance.metadata); + return val; +} + +const _$HealthDataTypeEnumMap = { + HealthDataType.ACTIVE_ENERGY_BURNED: 'ACTIVE_ENERGY_BURNED', + HealthDataType.AUDIOGRAM: 'AUDIOGRAM', + HealthDataType.BASAL_ENERGY_BURNED: 'BASAL_ENERGY_BURNED', + HealthDataType.BLOOD_GLUCOSE: 'BLOOD_GLUCOSE', + HealthDataType.BLOOD_OXYGEN: 'BLOOD_OXYGEN', + HealthDataType.BLOOD_PRESSURE_DIASTOLIC: 'BLOOD_PRESSURE_DIASTOLIC', + HealthDataType.BLOOD_PRESSURE_SYSTOLIC: 'BLOOD_PRESSURE_SYSTOLIC', + HealthDataType.BODY_FAT_PERCENTAGE: 'BODY_FAT_PERCENTAGE', + HealthDataType.BODY_MASS_INDEX: 'BODY_MASS_INDEX', + HealthDataType.BODY_TEMPERATURE: 'BODY_TEMPERATURE', + HealthDataType.BODY_WATER_MASS: 'BODY_WATER_MASS', + HealthDataType.DIETARY_CARBS_CONSUMED: 'DIETARY_CARBS_CONSUMED', + HealthDataType.DIETARY_CAFFEINE: 'DIETARY_CAFFEINE', + HealthDataType.DIETARY_ENERGY_CONSUMED: 'DIETARY_ENERGY_CONSUMED', + HealthDataType.DIETARY_FATS_CONSUMED: 'DIETARY_FATS_CONSUMED', + HealthDataType.DIETARY_PROTEIN_CONSUMED: 'DIETARY_PROTEIN_CONSUMED', + HealthDataType.FORCED_EXPIRATORY_VOLUME: 'FORCED_EXPIRATORY_VOLUME', + HealthDataType.HEART_RATE: 'HEART_RATE', + HealthDataType.HEART_RATE_VARIABILITY_SDNN: 'HEART_RATE_VARIABILITY_SDNN', + HealthDataType.HEIGHT: 'HEIGHT', + HealthDataType.INSULIN_DELIVERY: 'INSULIN_DELIVERY', + HealthDataType.RESTING_HEART_RATE: 'RESTING_HEART_RATE', + HealthDataType.RESPIRATORY_RATE: 'RESPIRATORY_RATE', + HealthDataType.PERIPHERAL_PERFUSION_INDEX: 'PERIPHERAL_PERFUSION_INDEX', + HealthDataType.STEPS: 'STEPS', + HealthDataType.WAIST_CIRCUMFERENCE: 'WAIST_CIRCUMFERENCE', + HealthDataType.WALKING_HEART_RATE: 'WALKING_HEART_RATE', + HealthDataType.WEIGHT: 'WEIGHT', + HealthDataType.DISTANCE_WALKING_RUNNING: 'DISTANCE_WALKING_RUNNING', + HealthDataType.DISTANCE_SWIMMING: 'DISTANCE_SWIMMING', + HealthDataType.DISTANCE_CYCLING: 'DISTANCE_CYCLING', + HealthDataType.FLIGHTS_CLIMBED: 'FLIGHTS_CLIMBED', + HealthDataType.MOVE_MINUTES: 'MOVE_MINUTES', + HealthDataType.DISTANCE_DELTA: 'DISTANCE_DELTA', + HealthDataType.MINDFULNESS: 'MINDFULNESS', + HealthDataType.WATER: 'WATER', + HealthDataType.SLEEP_IN_BED: 'SLEEP_IN_BED', + HealthDataType.SLEEP_ASLEEP: 'SLEEP_ASLEEP', + HealthDataType.SLEEP_ASLEEP_CORE: 'SLEEP_ASLEEP_CORE', + HealthDataType.SLEEP_ASLEEP_DEEP: 'SLEEP_ASLEEP_DEEP', + HealthDataType.SLEEP_ASLEEP_REM: 'SLEEP_ASLEEP_REM', + HealthDataType.SLEEP_AWAKE: 'SLEEP_AWAKE', + HealthDataType.SLEEP_LIGHT: 'SLEEP_LIGHT', + HealthDataType.SLEEP_DEEP: 'SLEEP_DEEP', + HealthDataType.SLEEP_REM: 'SLEEP_REM', + HealthDataType.SLEEP_OUT_OF_BED: 'SLEEP_OUT_OF_BED', + HealthDataType.SLEEP_SESSION: 'SLEEP_SESSION', + HealthDataType.EXERCISE_TIME: 'EXERCISE_TIME', + HealthDataType.WORKOUT: 'WORKOUT', + HealthDataType.HEADACHE_NOT_PRESENT: 'HEADACHE_NOT_PRESENT', + HealthDataType.HEADACHE_MILD: 'HEADACHE_MILD', + HealthDataType.HEADACHE_MODERATE: 'HEADACHE_MODERATE', + HealthDataType.HEADACHE_SEVERE: 'HEADACHE_SEVERE', + HealthDataType.HEADACHE_UNSPECIFIED: 'HEADACHE_UNSPECIFIED', + HealthDataType.NUTRITION: 'NUTRITION', + HealthDataType.HIGH_HEART_RATE_EVENT: 'HIGH_HEART_RATE_EVENT', + HealthDataType.LOW_HEART_RATE_EVENT: 'LOW_HEART_RATE_EVENT', + HealthDataType.IRREGULAR_HEART_RATE_EVENT: 'IRREGULAR_HEART_RATE_EVENT', + HealthDataType.ELECTRODERMAL_ACTIVITY: 'ELECTRODERMAL_ACTIVITY', + HealthDataType.ELECTROCARDIOGRAM: 'ELECTROCARDIOGRAM', + HealthDataType.TOTAL_CALORIES_BURNED: 'TOTAL_CALORIES_BURNED', +}; + +const _$HealthDataUnitEnumMap = { + HealthDataUnit.GRAM: 'GRAM', + HealthDataUnit.KILOGRAM: 'KILOGRAM', + HealthDataUnit.OUNCE: 'OUNCE', + HealthDataUnit.POUND: 'POUND', + HealthDataUnit.STONE: 'STONE', + HealthDataUnit.METER: 'METER', + HealthDataUnit.INCH: 'INCH', + HealthDataUnit.FOOT: 'FOOT', + HealthDataUnit.YARD: 'YARD', + HealthDataUnit.MILE: 'MILE', + HealthDataUnit.LITER: 'LITER', + HealthDataUnit.MILLILITER: 'MILLILITER', + HealthDataUnit.FLUID_OUNCE_US: 'FLUID_OUNCE_US', + HealthDataUnit.FLUID_OUNCE_IMPERIAL: 'FLUID_OUNCE_IMPERIAL', + HealthDataUnit.CUP_US: 'CUP_US', + HealthDataUnit.CUP_IMPERIAL: 'CUP_IMPERIAL', + HealthDataUnit.PINT_US: 'PINT_US', + HealthDataUnit.PINT_IMPERIAL: 'PINT_IMPERIAL', + HealthDataUnit.PASCAL: 'PASCAL', + HealthDataUnit.MILLIMETER_OF_MERCURY: 'MILLIMETER_OF_MERCURY', + HealthDataUnit.INCHES_OF_MERCURY: 'INCHES_OF_MERCURY', + HealthDataUnit.CENTIMETER_OF_WATER: 'CENTIMETER_OF_WATER', + HealthDataUnit.ATMOSPHERE: 'ATMOSPHERE', + HealthDataUnit.DECIBEL_A_WEIGHTED_SOUND_PRESSURE_LEVEL: + 'DECIBEL_A_WEIGHTED_SOUND_PRESSURE_LEVEL', + HealthDataUnit.SECOND: 'SECOND', + HealthDataUnit.MILLISECOND: 'MILLISECOND', + HealthDataUnit.MINUTE: 'MINUTE', + HealthDataUnit.HOUR: 'HOUR', + HealthDataUnit.DAY: 'DAY', + HealthDataUnit.JOULE: 'JOULE', + HealthDataUnit.KILOCALORIE: 'KILOCALORIE', + HealthDataUnit.LARGE_CALORIE: 'LARGE_CALORIE', + HealthDataUnit.SMALL_CALORIE: 'SMALL_CALORIE', + HealthDataUnit.DEGREE_CELSIUS: 'DEGREE_CELSIUS', + HealthDataUnit.DEGREE_FAHRENHEIT: 'DEGREE_FAHRENHEIT', + HealthDataUnit.KELVIN: 'KELVIN', + HealthDataUnit.DECIBEL_HEARING_LEVEL: 'DECIBEL_HEARING_LEVEL', + HealthDataUnit.HERTZ: 'HERTZ', + HealthDataUnit.SIEMEN: 'SIEMEN', + HealthDataUnit.VOLT: 'VOLT', + HealthDataUnit.INTERNATIONAL_UNIT: 'INTERNATIONAL_UNIT', + HealthDataUnit.COUNT: 'COUNT', + HealthDataUnit.PERCENT: 'PERCENT', + HealthDataUnit.BEATS_PER_MINUTE: 'BEATS_PER_MINUTE', + HealthDataUnit.RESPIRATIONS_PER_MINUTE: 'RESPIRATIONS_PER_MINUTE', + HealthDataUnit.MILLIGRAM_PER_DECILITER: 'MILLIGRAM_PER_DECILITER', + HealthDataUnit.UNKNOWN_UNIT: 'UNKNOWN_UNIT', + HealthDataUnit.NO_UNIT: 'NO_UNIT', +}; + +const _$HealthPlatformTypeEnumMap = { + HealthPlatformType.appleHealth: 'appleHealth', + HealthPlatformType.googleFit: 'googleFit', + HealthPlatformType.googleHealthConnect: 'googleHealthConnect', +}; + +HealthValue _$HealthValueFromJson(Map json) => + HealthValue()..$type = json['__type'] as String?; + +Map _$HealthValueToJson(HealthValue instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('__type', instance.$type); + return val; +} + +NumericHealthValue _$NumericHealthValueFromJson(Map json) => + NumericHealthValue( + numericValue: json['numeric_value'] as num, + )..$type = json['__type'] as String?; + +Map _$NumericHealthValueToJson(NumericHealthValue instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('__type', instance.$type); + val['numeric_value'] = instance.numericValue; + return val; +} + +AudiogramHealthValue _$AudiogramHealthValueFromJson( + Map json) => + AudiogramHealthValue( + frequencies: + (json['frequencies'] as List).map((e) => e as num).toList(), + leftEarSensitivities: (json['left_ear_sensitivities'] as List) + .map((e) => e as num) + .toList(), + rightEarSensitivities: (json['right_ear_sensitivities'] as List) + .map((e) => e as num) + .toList(), + )..$type = json['__type'] as String?; + +Map _$AudiogramHealthValueToJson( + AudiogramHealthValue instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('__type', instance.$type); + val['frequencies'] = instance.frequencies; + val['left_ear_sensitivities'] = instance.leftEarSensitivities; + val['right_ear_sensitivities'] = instance.rightEarSensitivities; + return val; +} + +WorkoutHealthValue _$WorkoutHealthValueFromJson(Map json) => + WorkoutHealthValue( + workoutActivityType: $enumDecode( + _$HealthWorkoutActivityTypeEnumMap, json['workout_activity_type']), + totalEnergyBurned: (json['total_energy_burned'] as num?)?.toInt(), + totalEnergyBurnedUnit: $enumDecodeNullable( + _$HealthDataUnitEnumMap, json['total_energy_burned_unit']), + totalDistance: (json['total_distance'] as num?)?.toInt(), + totalDistanceUnit: $enumDecodeNullable( + _$HealthDataUnitEnumMap, json['total_distance_unit']), + totalSteps: (json['total_steps'] as num?)?.toInt(), + totalStepsUnit: $enumDecodeNullable( + _$HealthDataUnitEnumMap, json['total_steps_unit']), + )..$type = json['__type'] as String?; + +Map _$WorkoutHealthValueToJson(WorkoutHealthValue instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('__type', instance.$type); + val['workout_activity_type'] = + _$HealthWorkoutActivityTypeEnumMap[instance.workoutActivityType]!; + writeNotNull('total_energy_burned', instance.totalEnergyBurned); + writeNotNull('total_energy_burned_unit', + _$HealthDataUnitEnumMap[instance.totalEnergyBurnedUnit]); + writeNotNull('total_distance', instance.totalDistance); + writeNotNull('total_distance_unit', + _$HealthDataUnitEnumMap[instance.totalDistanceUnit]); + writeNotNull('total_steps', instance.totalSteps); + writeNotNull( + 'total_steps_unit', _$HealthDataUnitEnumMap[instance.totalStepsUnit]); + return val; +} + +const _$HealthWorkoutActivityTypeEnumMap = { + HealthWorkoutActivityType.ARCHERY: 'ARCHERY', + HealthWorkoutActivityType.BADMINTON: 'BADMINTON', + HealthWorkoutActivityType.BASEBALL: 'BASEBALL', + HealthWorkoutActivityType.BASKETBALL: 'BASKETBALL', + HealthWorkoutActivityType.BIKING: 'BIKING', + HealthWorkoutActivityType.BOXING: 'BOXING', + HealthWorkoutActivityType.CRICKET: 'CRICKET', + HealthWorkoutActivityType.CURLING: 'CURLING', + HealthWorkoutActivityType.ELLIPTICAL: 'ELLIPTICAL', + HealthWorkoutActivityType.FENCING: 'FENCING', + HealthWorkoutActivityType.AMERICAN_FOOTBALL: 'AMERICAN_FOOTBALL', + HealthWorkoutActivityType.AUSTRALIAN_FOOTBALL: 'AUSTRALIAN_FOOTBALL', + HealthWorkoutActivityType.SOCCER: 'SOCCER', + HealthWorkoutActivityType.GOLF: 'GOLF', + HealthWorkoutActivityType.GYMNASTICS: 'GYMNASTICS', + HealthWorkoutActivityType.HANDBALL: 'HANDBALL', + HealthWorkoutActivityType.HIGH_INTENSITY_INTERVAL_TRAINING: + 'HIGH_INTENSITY_INTERVAL_TRAINING', + HealthWorkoutActivityType.HIKING: 'HIKING', + HealthWorkoutActivityType.HOCKEY: 'HOCKEY', + HealthWorkoutActivityType.SKATING: 'SKATING', + HealthWorkoutActivityType.JUMP_ROPE: 'JUMP_ROPE', + HealthWorkoutActivityType.KICKBOXING: 'KICKBOXING', + HealthWorkoutActivityType.MARTIAL_ARTS: 'MARTIAL_ARTS', + HealthWorkoutActivityType.PILATES: 'PILATES', + HealthWorkoutActivityType.RACQUETBALL: 'RACQUETBALL', + HealthWorkoutActivityType.ROWING: 'ROWING', + HealthWorkoutActivityType.RUGBY: 'RUGBY', + HealthWorkoutActivityType.RUNNING: 'RUNNING', + HealthWorkoutActivityType.SAILING: 'SAILING', + HealthWorkoutActivityType.CROSS_COUNTRY_SKIING: 'CROSS_COUNTRY_SKIING', + HealthWorkoutActivityType.DOWNHILL_SKIING: 'DOWNHILL_SKIING', + HealthWorkoutActivityType.SNOWBOARDING: 'SNOWBOARDING', + HealthWorkoutActivityType.SOFTBALL: 'SOFTBALL', + HealthWorkoutActivityType.SQUASH: 'SQUASH', + HealthWorkoutActivityType.STAIR_CLIMBING: 'STAIR_CLIMBING', + HealthWorkoutActivityType.SWIMMING: 'SWIMMING', + HealthWorkoutActivityType.TABLE_TENNIS: 'TABLE_TENNIS', + HealthWorkoutActivityType.TENNIS: 'TENNIS', + HealthWorkoutActivityType.VOLLEYBALL: 'VOLLEYBALL', + HealthWorkoutActivityType.WALKING: 'WALKING', + HealthWorkoutActivityType.WATER_POLO: 'WATER_POLO', + HealthWorkoutActivityType.YOGA: 'YOGA', + HealthWorkoutActivityType.BOWLING: 'BOWLING', + HealthWorkoutActivityType.CROSS_TRAINING: 'CROSS_TRAINING', + HealthWorkoutActivityType.TRACK_AND_FIELD: 'TRACK_AND_FIELD', + HealthWorkoutActivityType.DISC_SPORTS: 'DISC_SPORTS', + HealthWorkoutActivityType.LACROSSE: 'LACROSSE', + HealthWorkoutActivityType.PREPARATION_AND_RECOVERY: + 'PREPARATION_AND_RECOVERY', + HealthWorkoutActivityType.FLEXIBILITY: 'FLEXIBILITY', + HealthWorkoutActivityType.COOLDOWN: 'COOLDOWN', + HealthWorkoutActivityType.WHEELCHAIR_WALK_PACE: 'WHEELCHAIR_WALK_PACE', + HealthWorkoutActivityType.WHEELCHAIR_RUN_PACE: 'WHEELCHAIR_RUN_PACE', + HealthWorkoutActivityType.HAND_CYCLING: 'HAND_CYCLING', + HealthWorkoutActivityType.CORE_TRAINING: 'CORE_TRAINING', + HealthWorkoutActivityType.FUNCTIONAL_STRENGTH_TRAINING: + 'FUNCTIONAL_STRENGTH_TRAINING', + HealthWorkoutActivityType.TRADITIONAL_STRENGTH_TRAINING: + 'TRADITIONAL_STRENGTH_TRAINING', + HealthWorkoutActivityType.MIXED_CARDIO: 'MIXED_CARDIO', + HealthWorkoutActivityType.STAIRS: 'STAIRS', + HealthWorkoutActivityType.STEP_TRAINING: 'STEP_TRAINING', + HealthWorkoutActivityType.FITNESS_GAMING: 'FITNESS_GAMING', + HealthWorkoutActivityType.BARRE: 'BARRE', + HealthWorkoutActivityType.CARDIO_DANCE: 'CARDIO_DANCE', + HealthWorkoutActivityType.SOCIAL_DANCE: 'SOCIAL_DANCE', + HealthWorkoutActivityType.MIND_AND_BODY: 'MIND_AND_BODY', + HealthWorkoutActivityType.PICKLEBALL: 'PICKLEBALL', + HealthWorkoutActivityType.CLIMBING: 'CLIMBING', + HealthWorkoutActivityType.EQUESTRIAN_SPORTS: 'EQUESTRIAN_SPORTS', + HealthWorkoutActivityType.FISHING: 'FISHING', + HealthWorkoutActivityType.HUNTING: 'HUNTING', + HealthWorkoutActivityType.PLAY: 'PLAY', + HealthWorkoutActivityType.SNOW_SPORTS: 'SNOW_SPORTS', + HealthWorkoutActivityType.PADDLE_SPORTS: 'PADDLE_SPORTS', + HealthWorkoutActivityType.SURFING_SPORTS: 'SURFING_SPORTS', + HealthWorkoutActivityType.WATER_FITNESS: 'WATER_FITNESS', + HealthWorkoutActivityType.WATER_SPORTS: 'WATER_SPORTS', + HealthWorkoutActivityType.TAI_CHI: 'TAI_CHI', + HealthWorkoutActivityType.WRESTLING: 'WRESTLING', + HealthWorkoutActivityType.AEROBICS: 'AEROBICS', + HealthWorkoutActivityType.BIATHLON: 'BIATHLON', + HealthWorkoutActivityType.BIKING_HAND: 'BIKING_HAND', + HealthWorkoutActivityType.BIKING_MOUNTAIN: 'BIKING_MOUNTAIN', + HealthWorkoutActivityType.BIKING_ROAD: 'BIKING_ROAD', + HealthWorkoutActivityType.BIKING_SPINNING: 'BIKING_SPINNING', + HealthWorkoutActivityType.BIKING_STATIONARY: 'BIKING_STATIONARY', + HealthWorkoutActivityType.BIKING_UTILITY: 'BIKING_UTILITY', + HealthWorkoutActivityType.CALISTHENICS: 'CALISTHENICS', + HealthWorkoutActivityType.CIRCUIT_TRAINING: 'CIRCUIT_TRAINING', + HealthWorkoutActivityType.CROSS_FIT: 'CROSS_FIT', + HealthWorkoutActivityType.DANCING: 'DANCING', + HealthWorkoutActivityType.DIVING: 'DIVING', + HealthWorkoutActivityType.ELEVATOR: 'ELEVATOR', + HealthWorkoutActivityType.ERGOMETER: 'ERGOMETER', + HealthWorkoutActivityType.ESCALATOR: 'ESCALATOR', + HealthWorkoutActivityType.FRISBEE_DISC: 'FRISBEE_DISC', + HealthWorkoutActivityType.GARDENING: 'GARDENING', + HealthWorkoutActivityType.GUIDED_BREATHING: 'GUIDED_BREATHING', + HealthWorkoutActivityType.HORSEBACK_RIDING: 'HORSEBACK_RIDING', + HealthWorkoutActivityType.HOUSEWORK: 'HOUSEWORK', + HealthWorkoutActivityType.INTERVAL_TRAINING: 'INTERVAL_TRAINING', + HealthWorkoutActivityType.IN_VEHICLE: 'IN_VEHICLE', + HealthWorkoutActivityType.ICE_SKATING: 'ICE_SKATING', + HealthWorkoutActivityType.KAYAKING: 'KAYAKING', + HealthWorkoutActivityType.KETTLEBELL_TRAINING: 'KETTLEBELL_TRAINING', + HealthWorkoutActivityType.KICK_SCOOTER: 'KICK_SCOOTER', + HealthWorkoutActivityType.KITE_SURFING: 'KITE_SURFING', + HealthWorkoutActivityType.MEDITATION: 'MEDITATION', + HealthWorkoutActivityType.MIXED_MARTIAL_ARTS: 'MIXED_MARTIAL_ARTS', + HealthWorkoutActivityType.P90X: 'P90X', + HealthWorkoutActivityType.PARAGLIDING: 'PARAGLIDING', + HealthWorkoutActivityType.POLO: 'POLO', + HealthWorkoutActivityType.ROCK_CLIMBING: 'ROCK_CLIMBING', + HealthWorkoutActivityType.ROWING_MACHINE: 'ROWING_MACHINE', + HealthWorkoutActivityType.RUNNING_JOGGING: 'RUNNING_JOGGING', + HealthWorkoutActivityType.RUNNING_SAND: 'RUNNING_SAND', + HealthWorkoutActivityType.RUNNING_TREADMILL: 'RUNNING_TREADMILL', + HealthWorkoutActivityType.SCUBA_DIVING: 'SCUBA_DIVING', + HealthWorkoutActivityType.SKATING_CROSS: 'SKATING_CROSS', + HealthWorkoutActivityType.SKATING_INDOOR: 'SKATING_INDOOR', + HealthWorkoutActivityType.SKATING_INLINE: 'SKATING_INLINE', + HealthWorkoutActivityType.SKIING: 'SKIING', + HealthWorkoutActivityType.SKIING_BACK_COUNTRY: 'SKIING_BACK_COUNTRY', + HealthWorkoutActivityType.SKIING_KITE: 'SKIING_KITE', + HealthWorkoutActivityType.SKIING_ROLLER: 'SKIING_ROLLER', + HealthWorkoutActivityType.SLEDDING: 'SLEDDING', + HealthWorkoutActivityType.SNOWMOBILE: 'SNOWMOBILE', + HealthWorkoutActivityType.SNOWSHOEING: 'SNOWSHOEING', + HealthWorkoutActivityType.STAIR_CLIMBING_MACHINE: 'STAIR_CLIMBING_MACHINE', + HealthWorkoutActivityType.STANDUP_PADDLEBOARDING: 'STANDUP_PADDLEBOARDING', + HealthWorkoutActivityType.STILL: 'STILL', + HealthWorkoutActivityType.STRENGTH_TRAINING: 'STRENGTH_TRAINING', + HealthWorkoutActivityType.SURFING: 'SURFING', + HealthWorkoutActivityType.SWIMMING_OPEN_WATER: 'SWIMMING_OPEN_WATER', + HealthWorkoutActivityType.SWIMMING_POOL: 'SWIMMING_POOL', + HealthWorkoutActivityType.TEAM_SPORTS: 'TEAM_SPORTS', + HealthWorkoutActivityType.TILTING: 'TILTING', + HealthWorkoutActivityType.VOLLEYBALL_BEACH: 'VOLLEYBALL_BEACH', + HealthWorkoutActivityType.VOLLEYBALL_INDOOR: 'VOLLEYBALL_INDOOR', + HealthWorkoutActivityType.WAKEBOARDING: 'WAKEBOARDING', + HealthWorkoutActivityType.WALKING_FITNESS: 'WALKING_FITNESS', + HealthWorkoutActivityType.WALKING_NORDIC: 'WALKING_NORDIC', + HealthWorkoutActivityType.WALKING_STROLLER: 'WALKING_STROLLER', + HealthWorkoutActivityType.WALKING_TREADMILL: 'WALKING_TREADMILL', + HealthWorkoutActivityType.WEIGHTLIFTING: 'WEIGHTLIFTING', + HealthWorkoutActivityType.WHEELCHAIR: 'WHEELCHAIR', + HealthWorkoutActivityType.WINDSURFING: 'WINDSURFING', + HealthWorkoutActivityType.ZUMBA: 'ZUMBA', + HealthWorkoutActivityType.OTHER: 'OTHER', +}; + +ElectrocardiogramHealthValue _$ElectrocardiogramHealthValueFromJson( + Map json) => + ElectrocardiogramHealthValue( + voltageValues: (json['voltage_values'] as List) + .map((e) => + ElectrocardiogramVoltageValue.fromJson(e as Map)) + .toList(), + averageHeartRate: json['average_heart_rate'] as num?, + samplingFrequency: (json['sampling_frequency'] as num?)?.toDouble(), + classification: $enumDecodeNullable( + _$ElectrocardiogramClassificationEnumMap, json['classification']), + )..$type = json['__type'] as String?; + +Map _$ElectrocardiogramHealthValueToJson( + ElectrocardiogramHealthValue instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('__type', instance.$type); + val['voltage_values'] = instance.voltageValues; + writeNotNull('average_heart_rate', instance.averageHeartRate); + writeNotNull('sampling_frequency', instance.samplingFrequency); + writeNotNull('classification', + _$ElectrocardiogramClassificationEnumMap[instance.classification]); + return val; +} + +const _$ElectrocardiogramClassificationEnumMap = { + ElectrocardiogramClassification.NOT_SET: 'NOT_SET', + ElectrocardiogramClassification.SINUS_RHYTHM: 'SINUS_RHYTHM', + ElectrocardiogramClassification.ATRIAL_FIBRILLATION: 'ATRIAL_FIBRILLATION', + ElectrocardiogramClassification.INCONCLUSIVE_LOW_HEART_RATE: + 'INCONCLUSIVE_LOW_HEART_RATE', + ElectrocardiogramClassification.INCONCLUSIVE_HIGH_HEART_RATE: + 'INCONCLUSIVE_HIGH_HEART_RATE', + ElectrocardiogramClassification.INCONCLUSIVE_POOR_READING: + 'INCONCLUSIVE_POOR_READING', + ElectrocardiogramClassification.INCONCLUSIVE_OTHER: 'INCONCLUSIVE_OTHER', + ElectrocardiogramClassification.UNRECOGNIZED: 'UNRECOGNIZED', +}; + +ElectrocardiogramVoltageValue _$ElectrocardiogramVoltageValueFromJson( + Map json) => + ElectrocardiogramVoltageValue( + voltage: json['voltage'] as num, + timeSinceSampleStart: json['time_since_sample_start'] as num, + )..$type = json['__type'] as String?; + +Map _$ElectrocardiogramVoltageValueToJson( + ElectrocardiogramVoltageValue instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('__type', instance.$type); + val['voltage'] = instance.voltage; + val['time_since_sample_start'] = instance.timeSinceSampleStart; + return val; +} + +InsulinDeliveryHealthValue _$InsulinDeliveryHealthValueFromJson( + Map json) => + InsulinDeliveryHealthValue( + units: (json['units'] as num).toDouble(), + reason: $enumDecode(_$InsulinDeliveryReasonEnumMap, json['reason']), + )..$type = json['__type'] as String?; + +Map _$InsulinDeliveryHealthValueToJson( + InsulinDeliveryHealthValue instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('__type', instance.$type); + val['units'] = instance.units; + val['reason'] = _$InsulinDeliveryReasonEnumMap[instance.reason]!; + return val; +} + +const _$InsulinDeliveryReasonEnumMap = { + InsulinDeliveryReason.NOT_SET: 'NOT_SET', + InsulinDeliveryReason.BASAL: 'BASAL', + InsulinDeliveryReason.BOLUS: 'BOLUS', +}; + +NutritionHealthValue _$NutritionHealthValueFromJson( + Map json) => + NutritionHealthValue( + mealType: json['meal_type'] as String?, + protein: (json['protein'] as num?)?.toDouble(), + calories: (json['calories'] as num?)?.toDouble(), + fat: (json['fat'] as num?)?.toDouble(), + name: json['name'] as String?, + carbs: (json['carbs'] as num?)?.toDouble(), + caffeine: (json['caffeine'] as num?)?.toDouble(), + )..$type = json['__type'] as String?; + +Map _$NutritionHealthValueToJson( + NutritionHealthValue instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('__type', instance.$type); + writeNotNull('meal_type', instance.mealType); + writeNotNull('protein', instance.protein); + writeNotNull('calories', instance.calories); + writeNotNull('fat', instance.fat); + writeNotNull('name', instance.name); + writeNotNull('carbs', instance.carbs); + writeNotNull('caffeine', instance.caffeine); + return val; +} + +WorkoutSummary _$WorkoutSummaryFromJson(Map json) => + WorkoutSummary( + workoutType: json['workout_type'] as String, + totalDistance: json['total_distance'] as num, + totalEnergyBurned: json['total_energy_burned'] as num, + totalSteps: json['total_steps'] as num, + ); + +Map _$WorkoutSummaryToJson(WorkoutSummary instance) => + { + 'workout_type': instance.workoutType, + 'total_distance': instance.totalDistance, + 'total_energy_burned': instance.totalEnergyBurned, + 'total_steps': instance.totalSteps, + }; diff --git a/packages/health/lib/health.json.dart b/packages/health/lib/health.json.dart new file mode 100644 index 000000000..72a43924b --- /dev/null +++ b/packages/health/lib/health.json.dart @@ -0,0 +1,25 @@ +part of 'health.dart'; + +bool _fromJsonFunctionsRegistered = false; + +/// Register all the fromJson functions for the health domain classes. +void _registerFromJsonFunctions() { + if (_fromJsonFunctionsRegistered) return; + + // Protocol classes + FromJsonFactory().registerAll([ + HealthValue(), + NumericHealthValue(numericValue: 12), + AudiogramHealthValue( + frequencies: [], + leftEarSensitivities: [], + rightEarSensitivities: [], + ), + WorkoutHealthValue(workoutActivityType: HealthWorkoutActivityType.AEROBICS), + ElectrocardiogramHealthValue(voltageValues: []), + ElectrocardiogramVoltageValue(voltage: 12, timeSinceSampleStart: 0), + NutritionHealthValue(), + ]); + + _fromJsonFunctionsRegistered = true; +} diff --git a/packages/health/lib/src/functions.dart b/packages/health/lib/src/functions.dart index 24d191fc6..cc17b988c 100644 --- a/packages/health/lib/src/functions.dart +++ b/packages/health/lib/src/functions.dart @@ -1,4 +1,4 @@ -part of health; +part of '../health.dart'; /// Custom Exception for the plugin. Used when a Health Data Type is requested, /// but not available on the current platform. @@ -11,9 +11,36 @@ class HealthException implements Exception { HealthException(this.dataType, this.cause); + @override String toString() => "Error requesting health data type '$dataType' - cause: $cause"; } -/// A list of supported platforms. -enum PlatformType { IOS, ANDROID } +/// The status of Google Health Connect. +/// +/// **NOTE** - The enum order is arbitrary. If you need the native value, +/// use [nativeValue] and not the index. +/// +/// Reference: +/// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#constants_1 +enum HealthConnectSdkStatus { + /// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#SDK_UNAVAILABLE() + sdkUnavailable(1), + + /// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED() + sdkUnavailableProviderUpdateRequired(2), + + /// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#SDK_AVAILABLE() + sdkAvailable(3); + + const HealthConnectSdkStatus(this.nativeValue); + + /// The native value that matches the value in the Android SDK. + final int nativeValue; + + factory HealthConnectSdkStatus.fromNativeValue(int value) { + return HealthConnectSdkStatus.values.firstWhere( + (e) => e.nativeValue == value, + orElse: () => HealthConnectSdkStatus.sdkUnavailable); + } +} diff --git a/packages/health/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index c38121fa5..2744da6a5 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -1,31 +1,69 @@ -part of health; +part of '../health.dart'; + +/// Types of health platforms. +enum HealthPlatformType { appleHealth, googleFit, googleHealthConnect } /// A [HealthDataPoint] object corresponds to a data point capture from -/// GoogleFit or Apple HealthKit with a [HealthValue] as value. +/// Apple HealthKit or Google Fit or Google Health Connect with a [HealthValue] +/// as value. +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class HealthDataPoint { - HealthValue _value; - HealthDataType _type; - HealthDataUnit _unit; - DateTime _dateFrom; - DateTime _dateTo; - PlatformType _platform; - String _deviceId; - String _sourceId; - String _sourceName; - Map? _metadata; - - HealthDataPoint( - this._value, - this._type, - this._unit, - this._dateFrom, - this._dateTo, - this._platform, - this._deviceId, - this._sourceId, - this._sourceName, - this._metadata, - ) { + /// The quantity value of the data point + HealthValue value; + + /// The type of the data point. + HealthDataType type; + + /// The data point type as a string. + String get typeString => type.name; + + /// The unit of the data point. + HealthDataUnit unit; + + /// The data point unit as a string. + String get unitString => unit.name; + + /// The start of the time interval. + DateTime dateFrom; + + /// The end of the time interval. + DateTime dateTo; + + /// The health platform that this data point was fetched. + HealthPlatformType sourcePlatform; + + /// The id of the device from which the data point was fetched. + String sourceDeviceId; + + /// The id of the source from which the data point was fetched. + String sourceId; + + /// The name of the source from which the data point was fetched. + String sourceName; + + /// The user entered state of the data point. + bool isManualEntry; + + /// The summary of the workout data point, if available. + WorkoutSummary? workoutSummary; + + /// The metadata for this data point. + Map? metadata; + + HealthDataPoint({ + required this.value, + required this.type, + required this.unit, + required this.dateFrom, + required this.dateTo, + required this.sourcePlatform, + required this.sourceDeviceId, + required this.sourceId, + required this.sourceName, + this.isManualEntry = false, + this.workoutSummary, + this.metadata, + }) { // set the value to minutes rather than the category // returned by the native API if (type == HealthDataType.MINDFULNESS || @@ -41,133 +79,111 @@ class HealthDataPoint { type == HealthDataType.SLEEP_LIGHT || type == HealthDataType.SLEEP_REM || type == HealthDataType.SLEEP_OUT_OF_BED) { - this._value = _convertMinutes(); + value = _convertMinutes(); } } /// Converts dateTo - dateFrom to minutes. - NumericHealthValue _convertMinutes() { - int ms = dateTo.millisecondsSinceEpoch - dateFrom.millisecondsSinceEpoch; - return NumericHealthValue(ms / (1000 * 60)); - } - - /// Converts a json object to the [HealthDataPoint] - factory HealthDataPoint.fromJson(json) { - HealthValue healthValue; - if (json['data_type'] == 'AUDIOGRAM') { - healthValue = AudiogramHealthValue.fromJson(json['value']); - } else if (json['data_type'] == 'WORKOUT') { - healthValue = WorkoutHealthValue.fromJson(json['value']); - } else { - healthValue = NumericHealthValue.fromJson(json['value']); + NumericHealthValue _convertMinutes() => NumericHealthValue( + numericValue: + (dateTo.millisecondsSinceEpoch - dateFrom.millisecondsSinceEpoch) / + (1000 * 60)); + + /// Create a [HealthDataPoint] from json. + factory HealthDataPoint.fromJson(Map json) => + _$HealthDataPointFromJson(json); + + /// Convert this [HealthDataPoint] to json. + Map toJson() => _$HealthDataPointToJson(this); + + /// Create a [HealthDataPoint] based on a health data point from native data format. + factory HealthDataPoint.fromHealthDataPoint( + HealthDataType dataType, + dynamic dataPoint, + ) { + // Handling different [HealthValue] types + HealthValue value = switch (dataType) { + HealthDataType.AUDIOGRAM => + AudiogramHealthValue.fromHealthDataPoint(dataPoint), + HealthDataType.WORKOUT => + WorkoutHealthValue.fromHealthDataPoint(dataPoint), + HealthDataType.ELECTROCARDIOGRAM => + ElectrocardiogramHealthValue.fromHealthDataPoint(dataPoint), + HealthDataType.NUTRITION => + NutritionHealthValue.fromHealthDataPoint(dataPoint), + HealthDataType.INSULIN_DELIVERY => + InsulinDeliveryHealthValue.fromHealthDataPoint(dataPoint), + _ => NumericHealthValue.fromHealthDataPoint(dataPoint), + }; + + final DateTime from = + DateTime.fromMillisecondsSinceEpoch(dataPoint['date_from'] as int); + final DateTime to = + DateTime.fromMillisecondsSinceEpoch(dataPoint['date_to'] as int); + final String sourceId = dataPoint["source_id"] as String; + final String sourceName = dataPoint["source_name"] as String; + final bool isManualEntry = dataPoint["is_manual_entry"] as bool? ?? false; + final Map? metadata = dataPoint["metadata"] == null + ? null + : Map.from(dataPoint["metadata"] as Map); + final unit = dataTypeToUnit[dataType] ?? HealthDataUnit.UNKNOWN_UNIT; + + // Set WorkoutSummary, if available. + WorkoutSummary? workoutSummary; + if (dataPoint["workout_type"] != null || + dataPoint["total_distance"] != null || + dataPoint["total_energy_burned"] != null || + dataPoint["total_steps"] != null) { + workoutSummary = WorkoutSummary.fromHealthDataPoint(dataPoint); } return HealthDataPoint( - healthValue, - HealthDataType.values - .firstWhere((element) => element.name == json['data_type']), - HealthDataUnit.values - .firstWhere((element) => element.name == json['unit']), - DateTime.parse(json['date_from']), - DateTime.parse(json['date_to']), - PlatformTypeJsonValue.keys.toList()[ - PlatformTypeJsonValue.values.toList().indexOf(json['platform_type'])], - json['device_id'], - json['source_id'], - json['source_name'], - json['metadata'], + value: value, + type: dataType, + unit: unit, + dateFrom: from, + dateTo: to, + sourcePlatform: Health().platformType, + sourceDeviceId: Health().deviceId, + sourceId: sourceId, + sourceName: sourceName, + isManualEntry: isManualEntry, + workoutSummary: workoutSummary, + metadata: metadata, ); } - /// Converts the [HealthDataPoint] to a json object - Map toJson() => { - 'value': value.toJson(), - 'data_type': type.name, - 'unit': unit.name, - 'date_from': dateFrom.toIso8601String(), - 'date_to': dateTo.toIso8601String(), - 'platform_type': PlatformTypeJsonValue[platform], - 'device_id': deviceId, - 'source_id': sourceId, - 'source_name': sourceName, - 'metadata': metadata, - }; - @override - String toString() => """${this.runtimeType} - + String toString() => """$runtimeType - value: ${value.toString()}, unit: ${unit.name}, dateFrom: $dateFrom, dateTo: $dateTo, dataType: ${type.name}, - platform: $platform, - deviceId: $deviceId, + platform: $sourcePlatform, + deviceId: $sourceDeviceId, sourceId: $sourceId, sourceName: $sourceName + isManualEntry: $isManualEntry + workoutSummary: $workoutSummary metadata: $metadata"""; - /// The quantity value of the data point - HealthValue get value => _value; - - /// The start of the time interval - DateTime get dateFrom => _dateFrom; - - /// The end of the time interval - DateTime get dateTo => _dateTo; - - /// The type of the data point - HealthDataType get type => _type; - - /// The unit of the data point - HealthDataUnit get unit => _unit; - - /// The software platform of the data point - PlatformType get platform => _platform; - - /// The data point type as a string - String get typeString => _type.name; - - /// The data point unit as a string - String get unitString => _unit.name; - - /// The id of the device from which the data point was fetched. - String get deviceId => _deviceId; - - /// The id of the source from which the data point was fetched. - String get sourceId => _sourceId; - - /// The name of the source from which the data point was fetched. - String get sourceName => _sourceName; - - /// Metadata contains extra information describing the data point. - Map? get metadata => _metadata; - @override - bool operator ==(Object o) { - return o is HealthDataPoint && - this.value == o.value && - this.unit == o.unit && - this.dateFrom == o.dateFrom && - this.dateTo == o.dateTo && - this.type == o.type && - this.platform == o.platform && - this.deviceId == o.deviceId && - this.sourceId == o.sourceId && - this.sourceName == o.sourceName && - this.metadata == o.metadata; - } + bool operator ==(Object other) => + other is HealthDataPoint && + value == other.value && + unit == other.unit && + dateFrom == other.dateFrom && + dateTo == other.dateTo && + type == other.type && + sourcePlatform == other.sourcePlatform && + sourceDeviceId == other.sourceDeviceId && + sourceId == other.sourceId && + sourceName == other.sourceName && + isManualEntry == other.isManualEntry && + metadata == other.metadata; @override - int get hashCode => Object.hash( - value, - unit, - dateFrom, - dateTo, - type, - platform, - deviceId, - sourceId, - sourceName, - metadata, - ); + int get hashCode => Object.hash(value, unit, dateFrom, dateTo, type, + sourcePlatform, sourceDeviceId, sourceId, sourceName, metadata); } diff --git a/packages/health/lib/src/health_factory.dart b/packages/health/lib/src/health_plugin.dart similarity index 51% rename from packages/health/lib/src/health_factory.dart rename to packages/health/lib/src/health_plugin.dart index 1f12d57eb..9b3ddc33b 100644 --- a/packages/health/lib/src/health_factory.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -1,67 +1,111 @@ -part of health; +part of '../health.dart'; -/// Main class for the Plugin. +/// Main class for the Plugin. This class works as a singleton and should be accessed +/// via `Health()` factory method. The plugin must be configured using the [configure] method +/// before used. /// -/// The plugin supports: +/// Overall, the plugin supports: /// -/// * handling permissions to access health data using the [hasPermissions], +/// * Handling permissions to access health data using the [hasPermissions], /// [requestAuthorization], [revokePermissions] methods. -/// * reading health data using the [getHealthDataFromTypes] method. -/// * writing health data using the [writeHealthData] method. -/// * accessing total step counts using the [getTotalStepsInInterval] method. -/// * cleaning up dublicate data points via the [removeDuplicates] method. -class HealthFactory { +/// * Reading health data using the [getHealthDataFromTypes] method. +/// * Writing health data using the [writeHealthData] method. +/// * Cleaning up duplicate data points via the [removeDuplicates] method. +/// +/// In addition, the plugin has a set of specialized methods for reading and writing +/// different types of health data: +/// +/// * Reading aggregate health data using the [getHealthIntervalDataFromTypes] +/// and [getHealthAggregateDataFromTypes] methods. +/// * Reading total step counts using the [getTotalStepsInInterval] method. +/// * Writing different types of specialized health data like the [writeWorkoutData], +/// [writeBloodPressure], [writeBloodOxygen], [writeAudiogram], [writeMeal], +/// [writeInsulinDelivery] methods. +class Health { static const MethodChannel _channel = MethodChannel('flutter_health'); + static final _instance = Health._(); + String? _deviceId; final _deviceInfo = DeviceInfoPlugin(); - late bool _useHealthConnectIfAvailable; + bool _useHealthConnectIfAvailable = false; - static PlatformType _platformType = - Platform.isAndroid ? PlatformType.ANDROID : PlatformType.IOS; + Health._() { + _registerFromJsonFunctions(); + } - /// The plugin was created to use Health Connect (if true) or Google Fit (if false). - bool get useHealthConnectIfAvailable => _useHealthConnectIfAvailable; + /// Get the singleton [Health] instance. + factory Health() => _instance; + + /// The type of platform of this device. + HealthPlatformType get platformType => Platform.isIOS + ? HealthPlatformType.appleHealth + : useHealthConnectIfAvailable + ? HealthPlatformType.googleHealthConnect + : HealthPlatformType.googleFit; + + /// The id of this device. + /// + /// On Android this is the [ID](https://developer.android.com/reference/android/os/Build#ID) of the BUILD. + /// On iOS this is the [identifierForVendor](https://developer.apple.com/documentation/uikit/uidevice/1620059-identifierforvendor) of the UIDevice. + String get deviceId => _deviceId ?? 'unknown'; + + /// Configure the health plugin. Must be called before using the plugin. + /// + /// If [useHealthConnectIfAvailable] is true, Google Health Connect on + /// Android will be used. Has no effect on iOS. + Future configure({bool useHealthConnectIfAvailable = false}) async { + _deviceId ??= Platform.isAndroid + ? (await _deviceInfo.androidInfo).id + : (await _deviceInfo.iosInfo).identifierForVendor; - HealthFactory({bool useHealthConnectIfAvailable = false}) { _useHealthConnectIfAvailable = useHealthConnectIfAvailable; - if (_useHealthConnectIfAvailable) - _channel.invokeMethod('useHealthConnectIfAvailable'); + if (_useHealthConnectIfAvailable) { + await _channel.invokeMethod('useHealthConnectIfAvailable'); + } } + /// Is this plugin using Health Connect (true) or Google Fit (false)? + /// + /// This is set in the [configure] method. + bool get useHealthConnectIfAvailable => _useHealthConnectIfAvailable; + /// Check if a given data type is available on the platform - bool isDataTypeAvailable(HealthDataType dataType) => - _platformType == PlatformType.ANDROID - ? _dataTypeKeysAndroid.contains(dataType) - : _dataTypeKeysIOS.contains(dataType); + bool isDataTypeAvailable(HealthDataType dataType) => Platform.isAndroid + ? dataTypeKeysAndroid.contains(dataType) + : dataTypeKeysIOS.contains(dataType); - /// Determines if the data types have been granted with the specified access rights. + /// Determines if the health data [types] have been granted with the specified + /// access rights [permissions]. /// /// Returns: /// - /// * true - if all of the data types have been granted with the specfied access rights. - /// * false - if any of the data types has not been granted with the specified access right(s) - /// * null - if it can not be determined if the data types have been granted with the specified access right(s). + /// * true - if all of the data types have been granted with the specified access rights. + /// * false - if any of the data types has not been granted with the specified access right(s). + /// * null - if it can not be determined if the data types have been granted with the specified access right(s). /// /// Parameters: /// - /// * [types] - List of [HealthDataType] whose permissions are to be checked. - /// * [permissions] - Optional. - /// + If unspecified, this method checks if each HealthDataType in [types] has been granted READ access. - /// + If specified, this method checks if each [HealthDataType] in [types] has been granted with the access specified in its + /// * [types] - List of [HealthDataType] whose permissions are to be checked. + /// * [permissions] - Optional. + /// + If unspecified, this method checks if each HealthDataType in [types] has been granted READ access. + /// + If specified, this method checks if each [HealthDataType] in [types] has been granted with the access specified in its /// corresponding entry in this list. The length of this list must be equal to that of [types]. /// - /// Caveat: + /// Caveat: /// - /// As Apple HealthKit will not disclose if READ access has been granted for a data type due to privacy concern, - /// this method can only return null to represent an undertermined status, if it is called on iOS + /// * As Apple HealthKit will not disclose if READ access has been granted for a data type due to privacy concern, + /// this method can only return null to represent an undetermined status, if it is called on iOS /// with a READ or READ_WRITE access. /// - /// On Android, this function returns true or false, depending on whether the specified access right has been granted. - Future hasPermissions(List types, - {List? permissions}) async { - if (permissions != null && permissions.length != types.length) + /// * On Android, this function returns true or false, depending on whether the specified access right has been granted. + Future hasPermissions( + List types, { + List? permissions, + }) async { + if (permissions != null && permissions.length != types.length) { throw ArgumentError( "The lists of types and permissions must be of same length."); + } final mTypes = List.from(types, growable: true); final mPermissions = permissions == null @@ -70,7 +114,7 @@ class HealthFactory { : permissions.map((permission) => permission.index).toList(); /// On Android, if BMI is requested, then also ask for weight and height - if (_platformType == PlatformType.ANDROID) _handleBMI(mTypes, mPermissions); + if (Platform.isAndroid) _handleBMI(mTypes, mPermissions); return await _channel.invokeMethod('hasPermissions', { "types": mTypes.map((type) => type.name).toList(), @@ -79,25 +123,89 @@ class HealthFactory { } /// Revokes permissions of all types. + /// /// Uses `disableFit()` on Google Fit. /// /// Not implemented on iOS as there is no way to programmatically remove access. Future revokePermissions() async { try { - if (_platformType == PlatformType.IOS) { + if (Platform.isIOS) { throw UnsupportedError( 'Revoke permissions is not supported on iOS. Please revoke permissions manually in the settings.'); } await _channel.invokeMethod('revokePermissions'); return; } catch (e) { - print(e); + debugPrint('$runtimeType - Exception in revokePermissions(): $e'); + } + } + + /// Returns the current status of Health Connect availability. + /// + /// See this for more info: + /// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#getSdkStatus(android.content.Context,kotlin.String) + /// + /// Android only. + Future getHealthConnectSdkStatus() async { + try { + if (Platform.isIOS) { + throw UnsupportedError('Health Connect is not available on iOS.'); + } + final int status = + (await _channel.invokeMethod('getHealthConnectSdkStatus'))!; + return HealthConnectSdkStatus.fromNativeValue(status); + } catch (e) { + debugPrint('$runtimeType - Exception in getHealthConnectSdkStatus(): $e'); + return null; } } - /// Requests permissions to access data types in Apple Health or Google Fit. + /// Prompt the user to install the Health Connect app via the installed store + /// (most likely Play Store). /// - /// Returns true if successful, false otherwise + /// Android only. + Future installHealthConnect() async { + try { + if (!Platform.isAndroid) { + throw UnsupportedError( + 'installHealthConnect is only available on Android'); + } + await _channel.invokeMethod('installHealthConnect'); + } catch (e) { + debugPrint('$runtimeType - Exception in installHealthConnect(): $e'); + } + } + + /// Disconnect from Google fit. + /// + /// Not supported on iOS and Google Health Connect, and the method does nothing. + Future disconnect( + List types, { + List? permissions, + }) async { + if (permissions != null && permissions.length != types.length) { + throw ArgumentError( + 'The length of [types] must be same as that of [permissions].'); + } + + final mTypes = List.from(types, growable: true); + final mPermissions = permissions == null + ? List.filled(types.length, HealthDataAccess.READ.index, + growable: true) + : permissions.map((permission) => permission.index).toList(); + + // on Android, if BMI is requested, then also ask for weight and height + if (Platform.isAndroid) _handleBMI(mTypes, mPermissions); + + List keys = mTypes.map((dataType) => dataType.name).toList(); + + return await _channel.invokeMethod( + 'disconnect', {'types': keys, "permissions": mPermissions}); + } + + /// Requests permissions to access health data [types]. + /// + /// Returns true if successful, false otherwise. /// /// Parameters: /// @@ -147,22 +255,19 @@ class HealthFactory { : permissions.map((permission) => permission.index).toList(); // on Android, if BMI is requested, then also ask for weight and height - if (_platformType == PlatformType.ANDROID) _handleBMI(mTypes, mPermissions); + if (Platform.isAndroid) _handleBMI(mTypes, mPermissions); List keys = mTypes.map((e) => e.name).toList(); - print( - '>> trying to get permissions for $keys with permissions $mPermissions'); final bool? isAuthorized = await _channel.invokeMethod( 'requestAuthorization', {'types': keys, "permissions": mPermissions}); - print('>> isAuthorized: $isAuthorized'); return isAuthorized ?? false; } /// Obtains health and weight if BMI is requested on Android. - static void _handleBMI(List mTypes, List mPermissions) { + void _handleBMI(List mTypes, List mPermissions) { final index = mTypes.indexOf(HealthDataType.BODY_MASS_INDEX); - if (index != -1 && _platformType == PlatformType.ANDROID) { + if (index != -1 && Platform.isAndroid) { if (!mTypes.contains(HealthDataType.WEIGHT)) { mTypes.add(HealthDataType.WEIGHT); mPermissions.add(mPermissions[index]); @@ -178,22 +283,25 @@ class HealthFactory { /// Calculate the BMI using the last observed height and weight values. Future> _computeAndroidBMI( - DateTime startTime, DateTime endTime) async { - List heights = - await _prepareQuery(startTime, endTime, HealthDataType.HEIGHT); + DateTime startTime, + DateTime endTime, + bool includeManualEntry, + ) async { + List heights = await _prepareQuery( + startTime, endTime, HealthDataType.HEIGHT, includeManualEntry); if (heights.isEmpty) { return []; } - List weights = - await _prepareQuery(startTime, endTime, HealthDataType.WEIGHT); + List weights = await _prepareQuery( + startTime, endTime, HealthDataType.WEIGHT, includeManualEntry); double h = (heights.last.value as NumericHealthValue).numericValue.toDouble(); const dataType = HealthDataType.BODY_MASS_INDEX; - final unit = _dataTypeToUnit[dataType]!; + final unit = dataTypeToUnit[dataType]!; final bmiHealthPoints = []; for (var i = 0; i < weights.length; i++) { @@ -201,16 +309,16 @@ class HealthFactory { (weights[i].value as NumericHealthValue).numericValue.toDouble() / (h * h); final x = HealthDataPoint( - NumericHealthValue(bmiValue), - dataType, - unit, - weights[i].dateFrom, - weights[i].dateTo, - _platformType, - _deviceId!, - '', - '', - null, + value: NumericHealthValue(numericValue: bmiValue), + type: dataType, + unit: unit, + dateFrom: weights[i].dateFrom, + dateTo: weights[i].dateTo, + sourcePlatform: platformType, + sourceDeviceId: _deviceId!, + sourceId: '', + sourceName: '', + isManualEntry: !includeManualEntry, ); bmiHealthPoints.add(x); @@ -218,45 +326,51 @@ class HealthFactory { return bmiHealthPoints; } - /// Saves health data into Apple Health or Google Fit. + /// Write health data. /// /// Returns true if successful, false otherwise. /// /// Parameters: - /// * [value] - the health data's value in double - /// * [type] - the value's HealthDataType - /// * [startTime] - the start time when this [value] is measured. - /// + It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when this [value] is measured. - /// + It must be equal to or later than [startTime]. - /// + Simply set [endTime] equal to [startTime] if the [value] is measured only at a specific point in time. - /// * [unit] - (iOS ONLY) the unit the health data is measured in. - /// - /// Values for Sleep and Headache are ignored and will be automatically assigned the coresponding value. - Future writeHealthData( - double value, - HealthDataType type, - DateTime startTime, - DateTime endTime, { + /// * [value] - the health data's value in double + /// * [unit] - **iOS ONLY** the unit the health data is measured in. + /// * [type] - the value's HealthDataType + /// * [startTime] - the start time when this [value] is measured. + /// It must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when this [value] is measured. + /// It must be equal to or later than [startTime]. + /// Simply set [endTime] equal to [startTime] if the [value] is measured + /// only at a specific point in time (default). + /// + /// Values for Sleep and Headache are ignored and will be automatically assigned + /// the default value. + Future writeHealthData({ + required double value, HealthDataUnit? unit, + required HealthDataType type, + required DateTime startTime, + DateTime? endTime, }) async { - if (type == HealthDataType.WORKOUT) + if (type == HealthDataType.WORKOUT) { throw ArgumentError( "Adding workouts should be done using the writeWorkoutData method."); - if (startTime.isAfter(endTime)) + } + endTime ??= startTime; + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } if ({ HealthDataType.HIGH_HEART_RATE_EVENT, HealthDataType.LOW_HEART_RATE_EVENT, HealthDataType.IRREGULAR_HEART_RATE_EVENT, HealthDataType.ELECTROCARDIOGRAM, }.contains(type) && - _platformType == PlatformType.IOS) + Platform.isIOS) { throw ArgumentError( - "$type - iOS doesnt support writing this data type in HealthKit"); + "$type - iOS does not support writing this data type in HealthKit"); + } // Assign default unit if not specified - unit ??= _dataTypeToUnit[type]!; + unit ??= dataTypeToUnit[type]!; // Align values to type in cases where the type defines the value. // E.g. SLEEP_IN_BED should have value 0 @@ -265,6 +379,9 @@ class HealthFactory { type == HealthDataType.SLEEP_IN_BED || type == HealthDataType.SLEEP_DEEP || type == HealthDataType.SLEEP_REM || + type == HealthDataType.SLEEP_ASLEEP_CORE || + type == HealthDataType.SLEEP_ASLEEP_DEEP || + type == HealthDataType.SLEEP_ASLEEP_REM || type == HealthDataType.HEADACHE_NOT_PRESENT || type == HealthDataType.HEADACHE_MILD || type == HealthDataType.HEADACHE_MODERATE || @@ -284,20 +401,25 @@ class HealthFactory { return success ?? false; } - /// Deletes all records of the given type for a given period of time + /// Deletes all records of the given [type] for a given period of time. /// /// Returns true if successful, false otherwise. /// /// Parameters: - /// * [type] - the value's HealthDataType - /// * [startTime] - the start time when this [value] is measured. - /// + It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when this [value] is measured. - /// + It must be equal to or later than [startTime]. - Future delete( - HealthDataType type, DateTime startTime, DateTime endTime) async { - if (startTime.isAfter(endTime)) + /// * [type] - the value's HealthDataType. + /// * [startTime] - the start time when this [value] is measured. + /// Must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when this [value] is measured. + /// Must be equal to or later than [startTime]. + Future delete({ + required HealthDataType type, + required DateTime startTime, + DateTime? endTime, + }) async { + endTime ??= startTime; + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } Map args = { 'dataTypeKey': type.name, @@ -308,22 +430,29 @@ class HealthFactory { return success ?? false; } - /// Saves blood pressure record into Apple Health or Google Fit. + /// Saves a blood pressure record. /// /// Returns true if successful, false otherwise. /// /// Parameters: - /// * [systolic] - the systolic part of the blood pressure - /// * [diastolic] - the diastolic part of the blood pressure - /// * [startTime] - the start time when this [value] is measured. - /// + It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when this [value] is measured. - /// + It must be equal to or later than [startTime]. - /// + Simply set [endTime] equal to [startTime] if the blood pressure is measured only at a specific point in time. - Future writeBloodPressure( - int systolic, int diastolic, DateTime startTime, DateTime endTime) async { - if (startTime.isAfter(endTime)) + /// * [systolic] - the systolic part of the blood pressure. + /// * [diastolic] - the diastolic part of the blood pressure. + /// * [startTime] - the start time when this [value] is measured. + /// Must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when this [value] is measured. + /// Must be equal to or later than [startTime]. + /// Simply set [endTime] equal to [startTime] if the blood pressure is measured + /// only at a specific point in time. If omitted, [endTime] is set to [startTime]. + Future writeBloodPressure({ + required int systolic, + required int diastolic, + required DateTime startTime, + DateTime? endTime, + }) async { + endTime ??= startTime; + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } Map args = { 'systolic': systolic, @@ -331,33 +460,42 @@ class HealthFactory { 'startTime': startTime.millisecondsSinceEpoch, 'endTime': endTime.millisecondsSinceEpoch }; - bool? success = await _channel.invokeMethod('writeBloodPressure', args); - return success ?? false; + return await _channel.invokeMethod('writeBloodPressure', args) == true; } - /// Saves blood oxygen saturation record into Apple Health or Google Fit/Health Connect. + /// Saves blood oxygen saturation record. /// /// Returns true if successful, false otherwise. /// /// Parameters: - /// * [saturation] - the saturation of the blood oxygen in percentage - /// * [flowRate] - optional supplemental oxygen flow rate, only supported on Google Fit (default 0.0) - /// * [startTime] - the start time when this [value] is measured. - /// + It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when this [value] is measured. - /// + It must be equal to or later than [startTime]. - /// + Simply set [endTime] equal to [startTime] if the blood oxygen saturation is measured only at a specific point in time. - Future writeBloodOxygen( - double saturation, DateTime startTime, DateTime endTime, - {double flowRate = 0.0}) async { - if (startTime.isAfter(endTime)) + /// * [saturation] - the saturation of the blood oxygen in percentage + /// * [flowRate] - optional supplemental oxygen flow rate, only supported on + /// Google Fit (default 0.0) + /// * [startTime] - the start time when this [saturation] is measured. + /// Must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when this [saturation] is measured. + /// Must be equal to or later than [startTime]. + /// Simply set [endTime] equal to [startTime] if the blood oxygen saturation + /// is measured only at a specific point in time (default). + Future writeBloodOxygen({ + required double saturation, + double flowRate = 0.0, + required DateTime startTime, + DateTime? endTime, + }) async { + endTime ??= startTime; + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } bool? success; - if (_platformType == PlatformType.IOS) { + if (Platform.isIOS) { success = await writeHealthData( - saturation, HealthDataType.BLOOD_OXYGEN, startTime, endTime); - } else if (_platformType == PlatformType.ANDROID) { + value: saturation, + type: HealthDataType.BLOOD_OXYGEN, + startTime: startTime, + endTime: endTime); + } else if (Platform.isAndroid) { Map args = { 'value': saturation, 'flowRate': flowRate, @@ -370,31 +508,35 @@ class HealthFactory { return success ?? false; } - /// Saves meal record into Apple Health or Google Fit. + /// Saves meal record into Apple Health or Google Fit / Health Connect. /// /// Returns true if successful, false otherwise. /// /// Parameters: - /// * [startTime] - the start time when the meal was consumed. - /// + It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when the meal was consumed. - /// + It must be equal to or later than [startTime]. - /// * [caloriesConsumed] - total calories consumed with this meal. - /// * [carbohydrates] - optional carbohydrates information. - /// * [protein] - optional protein information. - /// * [fatTotal] - optional total fat information. - /// * [name] - optional name information about this meal. - Future writeMeal( - DateTime startTime, - DateTime endTime, - double? caloriesConsumed, - double? carbohydrates, - double? protein, - double? fatTotal, - String? name, - MealType mealType) async { - if (startTime.isAfter(endTime)) + /// * [mealType] - the type of meal. + /// * [startTime] - the start time when the meal was consumed. + /// It must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when the meal was consumed. + /// It must be equal to or later than [startTime]. + /// * [caloriesConsumed] - total calories consumed with this meal. + /// * [carbohydrates] - optional carbohydrates information. + /// * [protein] - optional protein information. + /// * [fatTotal] - optional total fat information. + /// * [name] - optional name information about this meal. + Future writeMeal({ + required MealType mealType, + required DateTime startTime, + required DateTime endTime, + double? caloriesConsumed, + double? carbohydrates, + double? protein, + double? fatTotal, + String? name, + double? caffeine, + }) async { + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } Map args = { 'startTime': startTime.millisecondsSinceEpoch, @@ -404,46 +546,56 @@ class HealthFactory { 'protein': protein, 'fatTotal': fatTotal, 'name': name, + 'caffeine': caffeine, 'mealType': mealType.name, }; bool? success = await _channel.invokeMethod('writeMeal', args); return success ?? false; } - /// Saves audiogram into Apple Health. + /// Saves audiogram into Apple Health. Not supported on Android. /// /// Returns true if successful, false otherwise. /// /// Parameters: - /// * [frequencies] - array of frequencies of the test - /// * [leftEarSensitivities] threshold in decibel for the left ear - /// * [rightEarSensitivities] threshold in decibel for the left ear - /// * [startTime] - the start time when the audiogram is measured. - /// + It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when the audiogram is measured. - /// + It must be equal to or later than [startTime]. - /// + Simply set [endTime] equal to [startTime] if the audiogram is measured only at a specific point in time. - /// * [metadata] - optional map of keys, both HKMetadataKeyExternalUUID and HKMetadataKeyDeviceName are required - Future writeAudiogram( - List frequencies, - List leftEarSensitivities, - List rightEarSensitivities, - DateTime startTime, - DateTime endTime, - {Map? metadata}) async { + /// * [frequencies] - array of frequencies of the test + /// * [leftEarSensitivities] threshold in decibel for the left ear + /// * [rightEarSensitivities] threshold in decibel for the left ear + /// * [startTime] - the start time when the audiogram is measured. + /// It must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when the audiogram is measured. + /// It must be equal to or later than [startTime]. + /// Simply set [endTime] equal to [startTime] if the audiogram is measured + /// only at a specific point in time (default). + /// * [metadata] - optional map of keys, both HKMetadataKeyExternalUUID + /// and HKMetadataKeyDeviceName are required + Future writeAudiogram({ + required List frequencies, + required List leftEarSensitivities, + required List rightEarSensitivities, + required DateTime startTime, + DateTime? endTime, + Map? metadata, + }) async { if (frequencies.isEmpty || leftEarSensitivities.isEmpty || - rightEarSensitivities.isEmpty) + rightEarSensitivities.isEmpty) { throw ArgumentError( "frequencies, leftEarSensitivities and rightEarSensitivities can't be empty"); + } if (frequencies.length != leftEarSensitivities.length || - rightEarSensitivities.length != leftEarSensitivities.length) + rightEarSensitivities.length != leftEarSensitivities.length) { throw ArgumentError( "frequencies, leftEarSensitivities and rightEarSensitivities need to be of the same length"); - if (startTime.isAfter(endTime)) + } + endTime ??= startTime; + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); - if (_platformType == PlatformType.ANDROID) + } + if (Platform.isAndroid) { throw UnsupportedError("writeAudiogram is not supported on Android"); + } + Map args = { 'frequencies': frequencies, 'leftEarSensitivities': leftEarSensitivities, @@ -453,21 +605,33 @@ class HealthFactory { 'endTime': endTime.millisecondsSinceEpoch, 'metadata': metadata, }; - bool? success = await _channel.invokeMethod('writeAudiogram', args); - return success ?? false; + return await _channel.invokeMethod('writeAudiogram', args) == true; } + /// Saves insulin delivery record into Apple Health. + /// + /// Returns true if successful, false otherwise. + /// + /// Parameters: + /// * [units] - the number of units of insulin taken. + /// * [reason] - the insulin reason, basal or bolus. + /// * [startTime] - the start time when the meal was consumed. + /// It must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when the meal was consumed. + /// It must be equal to or later than [startTime]. Future writeInsulinDelivery( double units, InsulinDeliveryReason reason, DateTime startTime, DateTime endTime, ) async { - if (startTime.isAfter(endTime)) + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } - if (reason == InsulinDeliveryReason.NOT_SET) + if (reason == InsulinDeliveryReason.NOT_SET) { throw ArgumentError("set a valid insulin delivery reason"); + } Map args = { 'units': units, @@ -481,12 +645,17 @@ class HealthFactory { } /// Fetch a list of health data points based on [types]. - Future> getHealthDataFromTypes( - DateTime startTime, DateTime endTime, List types) async { + Future> getHealthDataFromTypes({ + required List types, + required DateTime startTime, + required DateTime endTime, + bool includeManualEntry = true, + }) async { List dataPoints = []; for (var type in types) { - final result = await _prepareQuery(startTime, endTime, type); + final result = + await _prepareQuery(startTime, endTime, type, includeManualEntry); dataPoints.addAll(result); } @@ -498,110 +667,211 @@ class HealthFactory { return removeDuplicates(dataPoints); } - /// Prepares a query, i.e. checks if the types are available, etc. + /// Fetch a list of health data points based on [types]. + Future> getHealthIntervalDataFromTypes( + {required DateTime startDate, + required DateTime endDate, + required List types, + required int interval, + bool includeManualEntry = true}) async { + List dataPoints = []; + + for (var type in types) { + final result = await _prepareIntervalQuery( + startDate, endDate, type, interval, includeManualEntry); + dataPoints.addAll(result); + } + + return removeDuplicates(dataPoints); + } + + /// Fetch a list of health data points based on [types]. + Future> getHealthAggregateDataFromTypes({ + required List types, + required DateTime startDate, + required DateTime endDate, + int activitySegmentDuration = 1, + bool includeManualEntry = true, + }) async { + List dataPoints = []; + + final result = await _prepareAggregateQuery( + startDate, endDate, types, activitySegmentDuration, includeManualEntry); + dataPoints.addAll(result); + + return removeDuplicates(dataPoints); + } + + /// Prepares an interval query, i.e. checks if the types are available, etc. Future> _prepareQuery( - DateTime startTime, DateTime endTime, HealthDataType dataType) async { + DateTime startTime, + DateTime endTime, + HealthDataType dataType, + bool includeManualEntry, + ) async { // Ask for device ID only once - _deviceId ??= _platformType == PlatformType.ANDROID + _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id : (await _deviceInfo.iosInfo).identifierForVendor; // If not implemented on platform, throw an exception if (!isDataTypeAvailable(dataType)) { throw HealthException( - dataType, 'Not available on platform $_platformType'); + dataType, 'Not available on platform $platformType'); } // If BodyMassIndex is requested on Android, calculate this manually - if (dataType == HealthDataType.BODY_MASS_INDEX && - _platformType == PlatformType.ANDROID) { - return _computeAndroidBMI(startTime, endTime); + if (dataType == HealthDataType.BODY_MASS_INDEX && Platform.isAndroid) { + return _computeAndroidBMI(startTime, endTime, includeManualEntry); + } + return await _dataQuery(startTime, endTime, dataType, includeManualEntry); + } + + /// Prepares an interval query, i.e. checks if the types are available, etc. + Future> _prepareIntervalQuery( + DateTime startDate, + DateTime endDate, + HealthDataType dataType, + int interval, + bool includeManualEntry) async { + // Ask for device ID only once + _deviceId ??= Platform.isAndroid + ? (await _deviceInfo.androidInfo).id + : (await _deviceInfo.iosInfo).identifierForVendor; + + // If not implemented on platform, throw an exception + if (!isDataTypeAvailable(dataType)) { + throw HealthException( + dataType, 'Not available on platform $platformType'); + } + + return await _dataIntervalQuery( + startDate, endDate, dataType, interval, includeManualEntry); + } + + /// Prepares an aggregate query, i.e. checks if the types are available, etc. + Future> _prepareAggregateQuery( + DateTime startDate, + DateTime endDate, + List dataTypes, + int activitySegmentDuration, + bool includeManualEntry) async { + // Ask for device ID only once + _deviceId ??= Platform.isAndroid + ? (await _deviceInfo.androidInfo).id + : (await _deviceInfo.iosInfo).identifierForVendor; + + for (var type in dataTypes) { + // If not implemented on platform, throw an exception + if (!isDataTypeAvailable(type)) { + throw HealthException(type, 'Not available on platform $platformType'); + } } - return await _dataQuery(startTime, endTime, dataType); + + return await _dataAggregateQuery(startDate, endDate, dataTypes, + activitySegmentDuration, includeManualEntry); } /// Fetches data points from Android/iOS native code. - Future> _dataQuery( - DateTime startTime, DateTime endTime, HealthDataType dataType) async { + Future> _dataQuery(DateTime startTime, DateTime endTime, + HealthDataType dataType, bool includeManualEntry) async { final args = { 'dataTypeKey': dataType.name, - 'dataUnitKey': _dataTypeToUnit[dataType]!.name, + 'dataUnitKey': dataTypeToUnit[dataType]!.name, 'startTime': startTime.millisecondsSinceEpoch, - 'endTime': endTime.millisecondsSinceEpoch + 'endTime': endTime.millisecondsSinceEpoch, + 'includeManualEntry': includeManualEntry }; final fetchedDataPoints = await _channel.invokeMethod('getData', args); - if (fetchedDataPoints != null) { - final mesg = { + if (fetchedDataPoints != null && fetchedDataPoints is List) { + final msg = { "dataType": dataType, "dataPoints": fetchedDataPoints, - "deviceId": '$_deviceId', }; const thresHold = 100; // If the no. of data points are larger than the threshold, // call the compute method to spawn an Isolate to do the parsing in a separate thread. if (fetchedDataPoints.length > thresHold) { - return compute(_parse, mesg); + return compute(_parse, msg); } - return _parse(mesg); + return _parse(msg); } else { return []; } } - /// Parses the fetched data points into a list of [HealthDataPoint]. - static List _parse(Map message) { - final dataType = message["dataType"]; - final dataPoints = message["dataPoints"]; - final device = message["deviceId"]; - final unit = _dataTypeToUnit[dataType]!; - final list = dataPoints.map((e) { - // Handling different [HealthValue] types - HealthValue value; - if (dataType == HealthDataType.AUDIOGRAM) { - value = AudiogramHealthValue.fromJson(e); - } else if (dataType == HealthDataType.WORKOUT) { - value = WorkoutHealthValue.fromJson(e); - } else if (dataType == HealthDataType.ELECTROCARDIOGRAM) { - value = ElectrocardiogramHealthValue.fromJson(e); - } else if (dataType == HealthDataType.NUTRITION) { - value = NutritionHealthValue.fromJson(e); - } else if (dataType == HealthDataType.INSULIN_DELIVERY) { - value = InsulinDeliveryHealthValue.fromJson(e); - } else { - value = NumericHealthValue(e['value']); - } - final DateTime from = DateTime.fromMillisecondsSinceEpoch(e['date_from']); - final DateTime to = DateTime.fromMillisecondsSinceEpoch(e['date_to']); - final String sourceId = e["source_id"]; - final String sourceName = e["source_name"]; - final Map? metadata = e["metadata"] == null - ? null - : Map.from(e["metadata"]); - return HealthDataPoint( - value, - dataType, - unit, - from, - to, - _platformType, - device, - sourceId, - sourceName, - metadata, - ); - }).toList(); + /// function for fetching statistic health data + Future> _dataIntervalQuery( + DateTime startDate, + DateTime endDate, + HealthDataType dataType, + int interval, + bool includeManualEntry) async { + final args = { + 'dataTypeKey': dataType.name, + 'dataUnitKey': dataTypeToUnit[dataType]!.name, + 'startTime': startDate.millisecondsSinceEpoch, + 'endTime': endDate.millisecondsSinceEpoch, + 'interval': interval, + 'includeManualEntry': includeManualEntry + }; + + final fetchedDataPoints = + await _channel.invokeMethod('getIntervalData', args); + if (fetchedDataPoints != null) { + final msg = { + "dataType": dataType, + "dataPoints": fetchedDataPoints, + }; + return _parse(msg); + } + return []; + } + + /// function for fetching statistic health data + Future> _dataAggregateQuery( + DateTime startDate, + DateTime endDate, + List dataTypes, + int activitySegmentDuration, + bool includeManualEntry) async { + final args = { + 'dataTypeKeys': dataTypes.map((dataType) => dataType.name).toList(), + 'startTime': startDate.millisecondsSinceEpoch, + 'endTime': endDate.millisecondsSinceEpoch, + 'activitySegmentDuration': activitySegmentDuration, + 'includeManualEntry': includeManualEntry + }; + + final fetchedDataPoints = + await _channel.invokeMethod('getAggregateData', args); - return list; + if (fetchedDataPoints != null) { + final msg = { + "dataType": HealthDataType.WORKOUT, + "dataPoints": fetchedDataPoints, + }; + return _parse(msg); + } + return []; } - /// Given an array of [HealthDataPoint]s, this method will return the array - /// without any duplicates. - static List removeDuplicates(List points) { - return LinkedHashSet.of(points).toList(); + List _parse(Map message) { + final dataType = message["dataType"] as HealthDataType; + final dataPoints = message["dataPoints"] as List; + + return dataPoints + .map((dataPoint) => + HealthDataPoint.fromHealthDataPoint(dataType, dataPoint)) + .toList(); } - /// Get the total numbner of steps within a specific time period. + /// Return a list of [HealthDataPoint] based on [points] with no duplicates. + List removeDuplicates(List points) => + LinkedHashSet.of(points).toList(); + + /// Get the total number of steps within a specific time period. /// Returns null if not successful. /// /// Is a fix according to https://stackoverflow.com/questions/29414386/step-count-retrieved-through-google-fit-api-does-not-match-step-count-displayed/29415091#29415091 @@ -621,61 +891,55 @@ class HealthFactory { } /// Assigns numbers to specific [HealthDataType]s. - int _alignValue(HealthDataType type) { - switch (type) { - case HealthDataType.SLEEP_IN_BED: - return 0; - case HealthDataType.SLEEP_AWAKE: - return 2; - case HealthDataType.SLEEP_ASLEEP: - return 3; - case HealthDataType.SLEEP_DEEP: - return 4; - case HealthDataType.SLEEP_REM: - return 5; - case HealthDataType.HEADACHE_UNSPECIFIED: - return 0; - case HealthDataType.HEADACHE_NOT_PRESENT: - return 1; - case HealthDataType.HEADACHE_MILD: - return 2; - case HealthDataType.HEADACHE_MODERATE: - return 3; - case HealthDataType.HEADACHE_SEVERE: - return 4; - default: - throw HealthException(type, - "HealthDataType was not aligned correctly - please report bug at https://github.com/cph-cachet/flutter-plugins/issues"); - } - } + int _alignValue(HealthDataType type) => switch (type) { + HealthDataType.SLEEP_IN_BED => 0, + HealthDataType.SLEEP_AWAKE => 2, + HealthDataType.SLEEP_ASLEEP => 3, + HealthDataType.SLEEP_DEEP => 4, + HealthDataType.SLEEP_REM => 5, + HealthDataType.SLEEP_ASLEEP_CORE => 3, + HealthDataType.SLEEP_ASLEEP_DEEP => 4, + HealthDataType.SLEEP_ASLEEP_REM => 5, + HealthDataType.HEADACHE_UNSPECIFIED => 0, + HealthDataType.HEADACHE_NOT_PRESENT => 1, + HealthDataType.HEADACHE_MILD => 2, + HealthDataType.HEADACHE_MODERATE => 3, + HealthDataType.HEADACHE_SEVERE => 4, + _ => throw HealthException(type, + "HealthDataType was not aligned correctly - please report bug at https://github.com/cph-cachet/flutter-plugins/issues"), + }; - /// Write workout data to Apple Health + /// Write workout data to Apple Health or Google Fit or Google Health Connect. /// - /// Returns true if successfully added workout data. + /// Returns true if the workout data was successfully added. /// /// Parameters: - /// - [activityType] The type of activity performed - /// - [start] The start time of the workout - /// - [end] The end time of the workout - /// - [totalEnergyBurned] The total energy burned during the workout - /// - [totalEnergyBurnedUnit] The UNIT used to measure [totalEnergyBurned] *ONLY FOR IOS* Default value is KILOCALORIE. - /// - [totalDistance] The total distance traveled during the workout - /// - [totalDistanceUnit] The UNIT used to measure [totalDistance] *ONLY FOR IOS* Default value is METER. - Future writeWorkoutData( - HealthWorkoutActivityType activityType, - DateTime start, - DateTime end, { + /// - [activityType] The type of activity performed. + /// - [start] The start time of the workout. + /// - [end] The end time of the workout. + /// - [totalEnergyBurned] The total energy burned during the workout. + /// - [totalEnergyBurnedUnit] The UNIT used to measure [totalEnergyBurned] + /// *ONLY FOR IOS* Default value is KILOCALORIE. + /// - [totalDistance] The total distance traveled during the workout. + /// - [totalDistanceUnit] The UNIT used to measure [totalDistance] + /// *ONLY FOR IOS* Default value is METER. + /// - [title] The title of the workout. + /// *ONLY FOR HEALTH CONNECT* Default value is the [activityType], e.g. "STRENGTH_TRAINING". + Future writeWorkoutData({ + required HealthWorkoutActivityType activityType, + required DateTime start, + required DateTime end, int? totalEnergyBurned, HealthDataUnit totalEnergyBurnedUnit = HealthDataUnit.KILOCALORIE, int? totalDistance, HealthDataUnit totalDistanceUnit = HealthDataUnit.METER, + String? title, }) async { // Check that value is on the current Platform - if (_platformType == PlatformType.IOS && !_isOnIOS(activityType)) { + if (Platform.isIOS && !_isOnIOS(activityType)) { throw HealthException(activityType, "Workout activity type $activityType is not supported on iOS"); - } else if (_platformType == PlatformType.ANDROID && - !_isOnAndroid(activityType)) { + } else if (Platform.isAndroid && !_isOnAndroid(activityType)) { throw HealthException(activityType, "Workout activity type $activityType is not supported on Android"); } @@ -687,9 +951,9 @@ class HealthFactory { 'totalEnergyBurnedUnit': totalEnergyBurnedUnit.name, 'totalDistance': totalDistance, 'totalDistanceUnit': totalDistanceUnit.name, + 'title': title, }; - final success = await _channel.invokeMethod('writeWorkoutData', args); - return success ?? false; + return await _channel.invokeMethod('writeWorkoutData', args) == true; } /// Check if the given [HealthWorkoutActivityType] is supported on the iOS platform diff --git a/packages/health/lib/src/health_value_types.dart b/packages/health/lib/src/health_value_types.dart index 946f01ba9..b85eb20a2 100644 --- a/packages/health/lib/src/health_value_types.dart +++ b/packages/health/lib/src/health_value_types.dart @@ -1,37 +1,47 @@ -part of health; +part of '../health.dart'; + +/// An abstract class for health values. +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) +class HealthValue extends Serializable { + HealthValue(); + + @override + Function get fromJsonFunction => _$HealthValueFromJson; + factory HealthValue.fromJson(Map json) => + FromJsonFactory().fromJson(json) as HealthValue; + @override + Map toJson() => _$HealthValueToJson(this); +} /// A numerical value from Apple HealthKit or Google Fit -/// such as integer or double. -/// E.g. 1, 2.9, -3 +/// such as integer or double. E.g. 1, 2.9, -3 /// /// Parameters: /// * [numericValue] - a [num] value for the [HealthDataPoint] +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class NumericHealthValue extends HealthValue { - num _numericValue; + /// A [num] value for the [HealthDataPoint]. + num numericValue; - NumericHealthValue(this._numericValue); + NumericHealthValue({required this.numericValue}); - /// A [num] value for the [HealthDataPoint]. - num get numericValue => _numericValue; + /// Create a [NumericHealthValue] based on a health data point from native data format. + factory NumericHealthValue.fromHealthDataPoint(dynamic dataPoint) => + NumericHealthValue(numericValue: dataPoint['value'] as num? ?? 0); @override - String toString() { - return numericValue.toString(); - } + String toString() => '$runtimeType - numericValue: $numericValue'; - /// Parses a json object to [NumericHealthValue] - factory NumericHealthValue.fromJson(json) { - return NumericHealthValue(num.parse(json['numericValue'])); - } - - Map toJson() => { - 'numericValue': numericValue.toString(), - }; + @override + Function get fromJsonFunction => _$NumericHealthValueFromJson; + factory NumericHealthValue.fromJson(Map json) => + FromJsonFactory().fromJson(json) as NumericHealthValue; + @override + Map toJson() => _$NumericHealthValueToJson(this); @override - bool operator ==(Object o) { - return o is NumericHealthValue && this._numericValue == o.numericValue; - } + bool operator ==(Object other) => + other is NumericHealthValue && numericValue == other.numericValue; @override int get hashCode => numericValue.hashCode; @@ -43,50 +53,50 @@ class NumericHealthValue extends HealthValue { /// * [frequencies] - array of frequencies of the test /// * [leftEarSensitivities] threshold in decibel for the left ear /// * [rightEarSensitivities] threshold in decibel for the left ear +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class AudiogramHealthValue extends HealthValue { - List _frequencies; - List _leftEarSensitivities; - List _rightEarSensitivities; - - AudiogramHealthValue(this._frequencies, this._leftEarSensitivities, - this._rightEarSensitivities); - /// Array of frequencies of the test. - List get frequencies => _frequencies; + List frequencies; /// Threshold in decibel for the left ear. - List get leftEarSensitivities => _leftEarSensitivities; + List leftEarSensitivities; /// Threshold in decibel for the right ear. - List get rightEarSensitivities => _rightEarSensitivities; + List rightEarSensitivities; + + AudiogramHealthValue({ + required this.frequencies, + required this.leftEarSensitivities, + required this.rightEarSensitivities, + }); + + /// Create a [AudiogramHealthValue] based on a health data point from native data format. + factory AudiogramHealthValue.fromHealthDataPoint(dynamic dataPoint) => + AudiogramHealthValue( + frequencies: List.from(dataPoint['frequencies'] as List), + leftEarSensitivities: + List.from(dataPoint['leftEarSensitivities'] as List), + rightEarSensitivities: + List.from(dataPoint['rightEarSensitivities'] as List)); @override - String toString() { - return """frequencies: ${frequencies.toString()}, + String toString() => """$runtimeType - frequencies: ${frequencies.toString()}, left ear sensitivities: ${leftEarSensitivities.toString()}, right ear sensitivities: ${rightEarSensitivities.toString()}"""; - } - - factory AudiogramHealthValue.fromJson(json) { - return AudiogramHealthValue( - List.from(json['frequencies']), - List.from(json['leftEarSensitivities']), - List.from(json['rightEarSensitivities'])); - } - Map toJson() => { - 'frequencies': frequencies.toString(), - 'leftEarSensitivities': leftEarSensitivities.toString(), - 'rightEarSensitivities': rightEarSensitivities.toString(), - }; + @override + Function get fromJsonFunction => _$AudiogramHealthValueFromJson; + factory AudiogramHealthValue.fromJson(Map json) => + FromJsonFactory().fromJson(json) as AudiogramHealthValue; + @override + Map toJson() => _$AudiogramHealthValueToJson(this); @override - bool operator ==(Object o) { - return o is AudiogramHealthValue && - listEquals(this._frequencies, o.frequencies) && - listEquals(this._leftEarSensitivities, o.leftEarSensitivities) && - listEquals(this._rightEarSensitivities, o.rightEarSensitivities); - } + bool operator ==(Object other) => + other is AudiogramHealthValue && + listEquals(frequencies, other.frequencies) && + listEquals(leftEarSensitivities, other.leftEarSensitivities) && + listEquals(rightEarSensitivities, other.rightEarSensitivities); @override int get hashCode => @@ -101,90 +111,108 @@ class AudiogramHealthValue extends HealthValue { /// * [totalEnergyBurnedUnit] - the unit of the total energy burned /// * [totalDistance] - the total distance of the workout /// * [totalDistanceUnit] - the unit of the total distance +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class WorkoutHealthValue extends HealthValue { - HealthWorkoutActivityType _workoutActivityType; - int? _totalEnergyBurned; - HealthDataUnit? _totalEnergyBurnedUnit; - int? _totalDistance; - HealthDataUnit? _totalDistanceUnit; - - WorkoutHealthValue( - this._workoutActivityType, - this._totalEnergyBurned, - this._totalEnergyBurnedUnit, - this._totalDistance, - this._totalDistanceUnit); - /// The type of the workout. - HealthWorkoutActivityType get workoutActivityType => _workoutActivityType; + HealthWorkoutActivityType workoutActivityType; /// The total energy burned during the workout. /// Might not be available for all workouts. - int? get totalEnergyBurned => _totalEnergyBurned; + int? totalEnergyBurned; /// The unit of the total energy burned during the workout. /// Might not be available for all workouts. - HealthDataUnit? get totalEnergyBurnedUnit => _totalEnergyBurnedUnit; + HealthDataUnit? totalEnergyBurnedUnit; /// The total distance covered during the workout. /// Might not be available for all workouts. - int? get totalDistance => _totalDistance; + int? totalDistance; /// The unit of the total distance covered during the workout. /// Might not be available for all workouts. - HealthDataUnit? get totalDistanceUnit => _totalDistanceUnit; - - factory WorkoutHealthValue.fromJson(json) { - return WorkoutHealthValue( - HealthWorkoutActivityType.values.firstWhere( - (element) => element.name == json['workoutActivityType']), - json['totalEnergyBurned'] != null - ? (json['totalEnergyBurned'] as num).toInt() - : null, - json['totalEnergyBurnedUnit'] != null - ? HealthDataUnit.values.firstWhere( - (element) => element.name == json['totalEnergyBurnedUnit']) - : null, - json['totalDistance'] != null - ? (json['totalDistance'] as num).toInt() - : null, - json['totalDistanceUnit'] != null - ? HealthDataUnit.values.firstWhere( - (element) => element.name == json['totalDistanceUnit']) - : null); - } + HealthDataUnit? totalDistanceUnit; + + /// The total steps covered during the workout. + /// Might not be available for all workouts. + int? totalSteps; + + /// The unit of the total steps covered during the workout. + /// Might not be available for all workouts. + HealthDataUnit? totalStepsUnit; + + WorkoutHealthValue( + {required this.workoutActivityType, + this.totalEnergyBurned, + this.totalEnergyBurnedUnit, + this.totalDistance, + this.totalDistanceUnit, + this.totalSteps, + this.totalStepsUnit}); + + /// Create a [WorkoutHealthValue] based on a health data point from native data format. + factory WorkoutHealthValue.fromHealthDataPoint(dynamic dataPoint) => + WorkoutHealthValue( + workoutActivityType: HealthWorkoutActivityType.values.firstWhere( + (element) => element.name == dataPoint['workoutActivityType']), + totalEnergyBurned: dataPoint['totalEnergyBurned'] != null + ? (dataPoint['totalEnergyBurned'] as num).toInt() + : null, + totalEnergyBurnedUnit: dataPoint['totalEnergyBurnedUnit'] != null + ? HealthDataUnit.values.firstWhere((element) => + element.name == dataPoint['totalEnergyBurnedUnit']) + : null, + totalDistance: dataPoint['totalDistance'] != null + ? (dataPoint['totalDistance'] as num).toInt() + : null, + totalDistanceUnit: dataPoint['totalDistanceUnit'] != null + ? HealthDataUnit.values.firstWhere( + (element) => element.name == dataPoint['totalDistanceUnit']) + : null, + totalSteps: dataPoint['totalSteps'] != null + ? (dataPoint['totalSteps'] as num).toInt() + : null, + totalStepsUnit: dataPoint['totalStepsUnit'] != null + ? HealthDataUnit.values.firstWhere( + (element) => element.name == dataPoint['totalStepsUnit']) + : null); @override - Map toJson() => { - 'workoutActivityType': _workoutActivityType.name, - 'totalEnergyBurned': _totalEnergyBurned, - 'totalEnergyBurnedUnit': _totalEnergyBurnedUnit?.name, - 'totalDistance': _totalDistance, - 'totalDistanceUnit': _totalDistanceUnit?.name, - }; + Function get fromJsonFunction => _$WorkoutHealthValueFromJson; + factory WorkoutHealthValue.fromJson(Map json) => + FromJsonFactory().fromJson(json) as WorkoutHealthValue; + @override + Map toJson() => _$WorkoutHealthValueToJson(this); @override - String toString() { - return """workoutActivityType: ${workoutActivityType.name}, + String toString() => + """$runtimeType - workoutActivityType: ${workoutActivityType.name}, totalEnergyBurned: $totalEnergyBurned, totalEnergyBurnedUnit: ${totalEnergyBurnedUnit?.name}, totalDistance: $totalDistance, - totalDistanceUnit: ${totalDistanceUnit?.name}"""; - } + totalDistanceUnit: ${totalDistanceUnit?.name} + totalSteps: $totalSteps, + totalStepsUnit: ${totalStepsUnit?.name}"""; @override - bool operator ==(Object o) { - return o is WorkoutHealthValue && - this.workoutActivityType == o.workoutActivityType && - this.totalEnergyBurned == o.totalEnergyBurned && - this.totalEnergyBurnedUnit == o.totalEnergyBurnedUnit && - this.totalDistance == o.totalDistance && - this.totalDistanceUnit == o.totalDistanceUnit; - } + bool operator ==(Object other) => + other is WorkoutHealthValue && + workoutActivityType == other.workoutActivityType && + totalEnergyBurned == other.totalEnergyBurned && + totalEnergyBurnedUnit == other.totalEnergyBurnedUnit && + totalDistance == other.totalDistance && + totalDistanceUnit == other.totalDistanceUnit && + totalSteps == other.totalSteps && + totalStepsUnit == other.totalStepsUnit; @override - int get hashCode => Object.hash(workoutActivityType, totalEnergyBurned, - totalEnergyBurnedUnit, totalDistance, totalDistanceUnit); + int get hashCode => Object.hash( + workoutActivityType, + totalEnergyBurned, + totalEnergyBurnedUnit, + totalDistance, + totalDistanceUnit, + totalSteps, + totalStepsUnit); } /// A [HealthValue] object for ECGs @@ -194,6 +222,7 @@ class WorkoutHealthValue extends HealthValue { /// * [averageHeartRate] - the average heart rate during the ECG (in BPM) /// * [samplingFrequency] - the frequency at which the Apple Watch sampled the voltage. /// * [classification] - an [ElectrocardiogramClassification] +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class ElectrocardiogramHealthValue extends HealthValue { /// An array of [ElectrocardiogramVoltageValue]s. List voltageValues; @@ -205,42 +234,42 @@ class ElectrocardiogramHealthValue extends HealthValue { double? samplingFrequency; /// An [ElectrocardiogramClassification]. - ElectrocardiogramClassification classification; + ElectrocardiogramClassification? classification; ElectrocardiogramHealthValue({ required this.voltageValues, - required this.averageHeartRate, - required this.samplingFrequency, - required this.classification, + this.averageHeartRate, + this.samplingFrequency, + this.classification, }); - /// Parses [ElectrocardiogramHealthValue] from JSON. - factory ElectrocardiogramHealthValue.fromJson(json) => + @override + Function get fromJsonFunction => _$ElectrocardiogramHealthValueFromJson; + factory ElectrocardiogramHealthValue.fromJson(Map json) => + FromJsonFactory().fromJson(json) as ElectrocardiogramHealthValue; + @override + Map toJson() => _$ElectrocardiogramHealthValueToJson(this); + + /// Create a [ElectrocardiogramHealthValue] based on a health data point from native data format. + factory ElectrocardiogramHealthValue.fromHealthDataPoint(dynamic dataPoint) => ElectrocardiogramHealthValue( - voltageValues: (json['voltageValues'] as List) - .map((e) => ElectrocardiogramVoltageValue.fromJson(e)) + voltageValues: (dataPoint['voltageValues'] as List) + .map((voltageValue) => + ElectrocardiogramVoltageValue.fromHealthDataPoint(voltageValue)) .toList(), - averageHeartRate: json['averageHeartRate'], - samplingFrequency: json['samplingFrequency'], + averageHeartRate: dataPoint['averageHeartRate'] as num?, + samplingFrequency: dataPoint['samplingFrequency'] as double?, classification: ElectrocardiogramClassification.values - .firstWhere((c) => c.value == json['classification']), + .firstWhere((c) => c.value == dataPoint['classification']), ); - Map toJson() => { - 'voltageValues': - voltageValues.map((e) => e.toJson()).toList(growable: false), - 'averageHeartRate': averageHeartRate, - 'samplingFrequency': samplingFrequency, - 'classification': classification.value, - }; - @override - bool operator ==(Object o) => - o is ElectrocardiogramHealthValue && - voltageValues == o.voltageValues && - averageHeartRate == o.averageHeartRate && - samplingFrequency == o.samplingFrequency && - classification == o.classification; + bool operator ==(Object other) => + other is ElectrocardiogramHealthValue && + voltageValues == other.voltageValues && + averageHeartRate == other.averageHeartRate && + samplingFrequency == other.samplingFrequency && + classification == other.classification; @override int get hashCode => Object.hash( @@ -248,10 +277,11 @@ class ElectrocardiogramHealthValue extends HealthValue { @override String toString() => - '${voltageValues.length} values, $averageHeartRate BPM, $samplingFrequency HZ, $classification'; + '$runtimeType - ${voltageValues.length} values, $averageHeartRate BPM, $samplingFrequency HZ, $classification'; } /// Single voltage value belonging to a [ElectrocardiogramHealthValue] +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class ElectrocardiogramVoltageValue extends HealthValue { /// Voltage of the ECG. num voltage; @@ -259,36 +289,45 @@ class ElectrocardiogramVoltageValue extends HealthValue { /// Time since the start of the ECG. num timeSinceSampleStart; - ElectrocardiogramVoltageValue(this.voltage, this.timeSinceSampleStart); + ElectrocardiogramVoltageValue({ + required this.voltage, + required this.timeSinceSampleStart, + }); - factory ElectrocardiogramVoltageValue.fromJson(json) => + /// Create a [ElectrocardiogramVoltageValue] based on a health data point from native data format. + factory ElectrocardiogramVoltageValue.fromHealthDataPoint( + dynamic dataPoint) => ElectrocardiogramVoltageValue( - json['voltage'], json['timeSinceSampleStart']); + voltage: dataPoint['voltage'] as num, + timeSinceSampleStart: dataPoint['timeSinceSampleStart'] as num); - Map toJson() => { - 'voltage': voltage, - 'timeSinceSampleStart': timeSinceSampleStart, - }; + @override + Function get fromJsonFunction => _$ElectrocardiogramVoltageValueFromJson; + factory ElectrocardiogramVoltageValue.fromJson(Map json) => + FromJsonFactory().fromJson(json) as ElectrocardiogramVoltageValue; + @override + Map toJson() => _$ElectrocardiogramVoltageValueToJson(this); @override - bool operator ==(Object o) => - o is ElectrocardiogramVoltageValue && - voltage == o.voltage && - timeSinceSampleStart == o.timeSinceSampleStart; + bool operator ==(Object other) => + other is ElectrocardiogramVoltageValue && + voltage == other.voltage && + timeSinceSampleStart == other.timeSinceSampleStart; @override int get hashCode => Object.hash(voltage, timeSinceSampleStart); @override - String toString() => voltage.toString(); + String toString() => '$runtimeType - voltage: $voltage'; } /// A [HealthValue] object from insulin delivery (iOS only) -/// Parameters: -/// * [units] - the amount of units of insulin taken -/// * [reason] - if it's basal, bolus or unknown reason for insulin dosage +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class InsulinDeliveryHealthValue extends HealthValue { + /// The amount of units of insulin taken double units; + + /// If it's basal, bolus or unknown reason for insulin dosage InsulinDeliveryReason reason; InsulinDeliveryHealthValue({ @@ -296,121 +335,135 @@ class InsulinDeliveryHealthValue extends HealthValue { required this.reason, }); - factory InsulinDeliveryHealthValue.fromJson(json) { - final units = json['value']; + factory InsulinDeliveryHealthValue.fromHealthDataPoint(dynamic dataPoint) { + final units = dataPoint['value'] as num; - final metadata = json['metadata'] == null + final metadata = dataPoint['metadata'] == null ? null - : Map.from(json['metadata']); + : Map.from( + dataPoint['metadata'] as Map); final reasonIndex = metadata == null || !metadata.containsKey('HKInsulinDeliveryReason') ? 0 : metadata['HKInsulinDeliveryReason'] as double; final reason = InsulinDeliveryReason.values[reasonIndex.toInt()]; - return InsulinDeliveryHealthValue(units: units, reason: reason); + return InsulinDeliveryHealthValue(units: units.toDouble(), reason: reason); } - Map toJson() => { - 'value': units.toString(), - 'reason': reason.index, - }; + @override + Function get fromJsonFunction => _$InsulinDeliveryHealthValueFromJson; + factory InsulinDeliveryHealthValue.fromJson(Map json) => + FromJsonFactory().fromJson(json) as InsulinDeliveryHealthValue; + @override + Map toJson() => _$InsulinDeliveryHealthValueToJson(this); @override - bool operator ==(Object o) => - o is InsulinDeliveryHealthValue && units == o.units && reason == o.reason; + bool operator ==(Object other) => + other is InsulinDeliveryHealthValue && + units == other.units && + reason == other.reason; @override int get hashCode => Object.hash(units, reason); @override - String toString() => '$units units, $reason reason'; + String toString() => '$runtimeType - units: $units, reason: $reason'; } -/// A [HealthValue] object for nutrition +/// A [HealthValue] object for nutrition. +/// /// Parameters: -/// * [protein] - the amount of protein in grams -/// * [calories] - the amount of calories in kcal -/// * [fat] - the amount of fat in grams -/// * [name] - the name of the food -/// * [carbs] - the amount of carbs in grams -/// * [mealType] - the type of meal +/// * [protein] - the amount of protein in grams +/// * [calories] - the amount of calories in kcal +/// * [fat] - the amount of fat in grams +/// * [name] - the name of the food +/// * [carbs] - the amount of carbs in grams +/// * [caffeine] - the amount of caffeine in grams +/// * [mealType] - the type of meal +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class NutritionHealthValue extends HealthValue { - double? _protein; - double? _calories; - double? _fat; - String? _name; - double? _carbs; - String _mealType; - - NutritionHealthValue(this._protein, this._calories, this._fat, this._name, - this._carbs, this._mealType); + /// The type of meal. + String? mealType; /// The amount of protein in grams. - double? get protein => _protein; + double? protein; /// The amount of calories in kcal. - double? get calories => _calories; + double? calories; /// The amount of fat in grams. - double? get fat => _fat; + double? fat; /// The name of the food. - String? get name => _name; + String? name; /// The amount of carbs in grams. - double? get carbs => _carbs; - - /// The type of meal. - String get mealType => _mealType; - - factory NutritionHealthValue.fromJson(json) { - return NutritionHealthValue( - json['protein'] != null ? (json['protein'] as num).toDouble() : null, - json['calories'] != null ? (json['calories'] as num).toDouble() : null, - json['fat'] != null ? (json['fat'] as num).toDouble() : null, - json['name'] != null ? (json['name'] as String) : null, - json['carbs'] != null ? (json['carbs'] as num).toDouble() : null, - json['mealType'] as String, - ); - } + double? carbs; + + /// The amount of caffeine in grams. + double? caffeine; + + NutritionHealthValue({ + this.mealType, + this.protein, + this.calories, + this.fat, + this.name, + this.carbs, + this.caffeine, + }); @override - Map toJson() => { - 'protein': _protein, - 'calories': _calories, - 'fat': _fat, - 'name': _name, - 'carbs': _carbs, - 'mealType': _mealType, - }; + Function get fromJsonFunction => _$NutritionHealthValueFromJson; + factory NutritionHealthValue.fromJson(Map json) => + FromJsonFactory().fromJson(json) as NutritionHealthValue; + @override + Map toJson() => _$NutritionHealthValueToJson(this); + + /// Create a [NutritionHealthValue] based on a health data point from native data format. + factory NutritionHealthValue.fromHealthDataPoint(dynamic dataPoint) => + NutritionHealthValue( + mealType: dataPoint['mealType'] as String, + protein: dataPoint['protein'] != null + ? (dataPoint['protein'] as num).toDouble() + : null, + calories: dataPoint['calories'] != null + ? (dataPoint['calories'] as num).toDouble() + : null, + fat: dataPoint['fat'] != null + ? (dataPoint['fat'] as num).toDouble() + : null, + name: dataPoint['name'] != null ? (dataPoint['name'] as String) : null, + carbs: dataPoint['carbs'] != null + ? (dataPoint['carbs'] as num).toDouble() + : null, + caffeine: dataPoint['caffeine'] != null + ? (dataPoint['caffeine'] as num).toDouble() + : null, + ); @override - String toString() { - return """protein: ${protein.toString()}, + String toString() => """$runtimeType - protein: ${protein.toString()}, calories: ${calories.toString()}, fat: ${fat.toString()}, name: ${name.toString()}, carbs: ${carbs.toString()}, + caffeine: ${caffeine.toString()}, mealType: $mealType"""; - } @override - bool operator ==(Object o) { - return o is NutritionHealthValue && - o.protein == this.protein && - o.calories == this.calories && - o.fat == this.fat && - o.name == this.name && - o.carbs == this.carbs && - o.mealType == this.mealType; - } + bool operator ==(Object other) => + other is NutritionHealthValue && + other.protein == protein && + other.calories == calories && + other.fat == fat && + other.name == name && + other.carbs == carbs && + other.caffeine == caffeine && + other.mealType == mealType; @override - int get hashCode => Object.hash(protein, calories, fat, name, carbs); -} - -/// An abstract class for health values. -abstract class HealthValue { - Map toJson(); + int get hashCode => + Object.hash(protein, calories, fat, name, carbs, caffeine); } diff --git a/packages/health/lib/src/data_types.dart b/packages/health/lib/src/heath_data_types.dart similarity index 85% rename from packages/health/lib/src/data_types.dart rename to packages/health/lib/src/heath_data_types.dart index b324e451d..28caa58f0 100644 --- a/packages/health/lib/src/data_types.dart +++ b/packages/health/lib/src/heath_data_types.dart @@ -1,6 +1,6 @@ -part of health; +part of '../health.dart'; -/// List of all available data types. +/// List of all available health data types. enum HealthDataType { ACTIVE_ENERGY_BURNED, AUDIOGRAM, @@ -12,7 +12,9 @@ enum HealthDataType { BODY_FAT_PERCENTAGE, BODY_MASS_INDEX, BODY_TEMPERATURE, + BODY_WATER_MASS, DIETARY_CARBS_CONSUMED, + DIETARY_CAFFEINE, DIETARY_ENERGY_CONSUMED, DIETARY_FATS_CONSUMED, DIETARY_PROTEIN_CONSUMED, @@ -29,6 +31,8 @@ enum HealthDataType { WALKING_HEART_RATE, WEIGHT, DISTANCE_WALKING_RUNNING, + DISTANCE_SWIMMING, + DISTANCE_CYCLING, FLIGHTS_CLIMBED, MOVE_MINUTES, DISTANCE_DELTA, @@ -36,6 +40,9 @@ enum HealthDataType { WATER, SLEEP_IN_BED, SLEEP_ASLEEP, + SLEEP_ASLEEP_CORE, + SLEEP_ASLEEP_DEEP, + SLEEP_ASLEEP_REM, SLEEP_AWAKE, SLEEP_LIGHT, SLEEP_DEEP, @@ -57,6 +64,9 @@ enum HealthDataType { IRREGULAR_HEART_RATE_EVENT, ELECTRODERMAL_ACTIVITY, ELECTROCARDIOGRAM, + + // Health Connect + TOTAL_CALORIES_BURNED } /// Access types for Health Data. @@ -66,8 +76,8 @@ enum HealthDataAccess { READ_WRITE, } -/// List of data types available on iOS -const List _dataTypeKeysIOS = [ +/// List of data types available on iOS. +const List dataTypeKeysIOS = [ HealthDataType.ACTIVE_ENERGY_BURNED, HealthDataType.AUDIOGRAM, HealthDataType.BASAL_ENERGY_BURNED, @@ -79,6 +89,7 @@ const List _dataTypeKeysIOS = [ HealthDataType.BODY_MASS_INDEX, HealthDataType.BODY_TEMPERATURE, HealthDataType.DIETARY_CARBS_CONSUMED, + HealthDataType.DIETARY_CAFFEINE, HealthDataType.DIETARY_ENERGY_CONSUMED, HealthDataType.DIETARY_FATS_CONSUMED, HealthDataType.DIETARY_PROTEIN_CONSUMED, @@ -100,12 +111,17 @@ const List _dataTypeKeysIOS = [ HealthDataType.WEIGHT, HealthDataType.FLIGHTS_CLIMBED, HealthDataType.DISTANCE_WALKING_RUNNING, + HealthDataType.DISTANCE_SWIMMING, + HealthDataType.DISTANCE_CYCLING, HealthDataType.MINDFULNESS, HealthDataType.SLEEP_IN_BED, HealthDataType.SLEEP_AWAKE, HealthDataType.SLEEP_ASLEEP, HealthDataType.SLEEP_DEEP, HealthDataType.SLEEP_REM, + HealthDataType.SLEEP_ASLEEP_CORE, + HealthDataType.SLEEP_ASLEEP_DEEP, + HealthDataType.SLEEP_ASLEEP_REM, HealthDataType.WATER, HealthDataType.EXERCISE_TIME, HealthDataType.WORKOUT, @@ -119,7 +135,7 @@ const List _dataTypeKeysIOS = [ ]; /// List of data types available on Android -const List _dataTypeKeysAndroid = [ +const List dataTypeKeysAndroid = [ HealthDataType.ACTIVE_ENERGY_BURNED, HealthDataType.BLOOD_GLUCOSE, HealthDataType.BLOOD_OXYGEN, @@ -128,6 +144,7 @@ const List _dataTypeKeysAndroid = [ HealthDataType.BODY_FAT_PERCENTAGE, HealthDataType.BODY_MASS_INDEX, HealthDataType.BODY_TEMPERATURE, + HealthDataType.BODY_WATER_MASS, HealthDataType.HEART_RATE, HealthDataType.HEIGHT, HealthDataType.STEPS, @@ -136,6 +153,7 @@ const List _dataTypeKeysAndroid = [ HealthDataType.DISTANCE_DELTA, HealthDataType.SLEEP_AWAKE, HealthDataType.SLEEP_ASLEEP, + HealthDataType.SLEEP_IN_BED, HealthDataType.SLEEP_DEEP, HealthDataType.SLEEP_LIGHT, HealthDataType.SLEEP_REM, @@ -148,10 +166,11 @@ const List _dataTypeKeysAndroid = [ HealthDataType.BASAL_ENERGY_BURNED, HealthDataType.RESPIRATORY_RATE, HealthDataType.NUTRITION, + HealthDataType.TOTAL_CALORIES_BURNED, ]; /// Maps a [HealthDataType] to a [HealthDataUnit]. -const Map _dataTypeToUnit = { +const Map dataTypeToUnit = { HealthDataType.ACTIVE_ENERGY_BURNED: HealthDataUnit.KILOCALORIE, HealthDataType.AUDIOGRAM: HealthDataUnit.DECIBEL_HEARING_LEVEL, HealthDataType.BASAL_ENERGY_BURNED: HealthDataUnit.KILOCALORIE, @@ -162,7 +181,9 @@ const Map _dataTypeToUnit = { HealthDataType.BODY_FAT_PERCENTAGE: HealthDataUnit.PERCENT, HealthDataType.BODY_MASS_INDEX: HealthDataUnit.NO_UNIT, HealthDataType.BODY_TEMPERATURE: HealthDataUnit.DEGREE_CELSIUS, + HealthDataType.BODY_WATER_MASS: HealthDataUnit.KILOGRAM, HealthDataType.DIETARY_CARBS_CONSUMED: HealthDataUnit.GRAM, + HealthDataType.DIETARY_CAFFEINE: HealthDataUnit.GRAM, HealthDataType.DIETARY_ENERGY_CONSUMED: HealthDataUnit.KILOCALORIE, HealthDataType.DIETARY_FATS_CONSUMED: HealthDataUnit.GRAM, HealthDataType.DIETARY_PROTEIN_CONSUMED: HealthDataUnit.GRAM, @@ -179,6 +200,8 @@ const Map _dataTypeToUnit = { HealthDataType.WALKING_HEART_RATE: HealthDataUnit.BEATS_PER_MINUTE, HealthDataType.WEIGHT: HealthDataUnit.KILOGRAM, HealthDataType.DISTANCE_WALKING_RUNNING: HealthDataUnit.METER, + HealthDataType.DISTANCE_SWIMMING: HealthDataUnit.METER, + HealthDataType.DISTANCE_CYCLING: HealthDataUnit.METER, HealthDataType.FLIGHTS_CLIMBED: HealthDataUnit.COUNT, HealthDataType.MOVE_MINUTES: HealthDataUnit.MINUTE, HealthDataType.DISTANCE_DELTA: HealthDataUnit.METER, @@ -186,6 +209,9 @@ const Map _dataTypeToUnit = { HealthDataType.WATER: HealthDataUnit.LITER, HealthDataType.SLEEP_IN_BED: HealthDataUnit.MINUTE, HealthDataType.SLEEP_ASLEEP: HealthDataUnit.MINUTE, + HealthDataType.SLEEP_ASLEEP_CORE: HealthDataUnit.MINUTE, + HealthDataType.SLEEP_ASLEEP_DEEP: HealthDataUnit.MINUTE, + HealthDataType.SLEEP_ASLEEP_REM: HealthDataUnit.MINUTE, HealthDataType.SLEEP_AWAKE: HealthDataUnit.MINUTE, HealthDataType.SLEEP_DEEP: HealthDataUnit.MINUTE, HealthDataType.SLEEP_REM: HealthDataUnit.MINUTE, @@ -211,13 +237,16 @@ const Map _dataTypeToUnit = { HealthDataType.ELECTROCARDIOGRAM: HealthDataUnit.VOLT, HealthDataType.NUTRITION: HealthDataUnit.NO_UNIT, -}; -const PlatformTypeJsonValue = { - PlatformType.IOS: 'ios', - PlatformType.ANDROID: 'android', + // Health Connect + HealthDataType.TOTAL_CALORIES_BURNED: HealthDataUnit.KILOCALORIE, }; +// const PlatformTypeJsonValue = { +// PlatformType.IOS: 'ios', +// PlatformType.ANDROID: 'android', +// }; + /// List of all [HealthDataUnit]s. enum HealthDataUnit { // Mass units @@ -300,8 +329,9 @@ enum HealthDataUnit { } /// List of [HealthWorkoutActivityType]s. -/// Commented for which platform they are supported enum HealthWorkoutActivityType { + // Commented for which platform the type are supported + // Both ARCHERY, BADMINTON, @@ -488,24 +518,14 @@ enum InsulinDeliveryReason { /// Extension to assign numbers to [ElectrocardiogramClassification]s extension ElectrocardiogramClassificationValue on ElectrocardiogramClassification { - int get value { - switch (this) { - case ElectrocardiogramClassification.NOT_SET: - return 0; - case ElectrocardiogramClassification.SINUS_RHYTHM: - return 1; - case ElectrocardiogramClassification.ATRIAL_FIBRILLATION: - return 2; - case ElectrocardiogramClassification.INCONCLUSIVE_LOW_HEART_RATE: - return 3; - case ElectrocardiogramClassification.INCONCLUSIVE_HIGH_HEART_RATE: - return 4; - case ElectrocardiogramClassification.INCONCLUSIVE_POOR_READING: - return 5; - case ElectrocardiogramClassification.INCONCLUSIVE_OTHER: - return 6; - case ElectrocardiogramClassification.UNRECOGNIZED: - return 100; - } - } + int get value => switch (this) { + ElectrocardiogramClassification.NOT_SET => 0, + ElectrocardiogramClassification.SINUS_RHYTHM => 1, + ElectrocardiogramClassification.ATRIAL_FIBRILLATION => 2, + ElectrocardiogramClassification.INCONCLUSIVE_LOW_HEART_RATE => 3, + ElectrocardiogramClassification.INCONCLUSIVE_HIGH_HEART_RATE => 4, + ElectrocardiogramClassification.INCONCLUSIVE_POOR_READING => 5, + ElectrocardiogramClassification.INCONCLUSIVE_OTHER => 6, + ElectrocardiogramClassification.UNRECOGNIZED => 100, + }; } diff --git a/packages/health/lib/src/workout_summary.dart b/packages/health/lib/src/workout_summary.dart new file mode 100644 index 000000000..c36613802 --- /dev/null +++ b/packages/health/lib/src/workout_summary.dart @@ -0,0 +1,52 @@ +part of '../health.dart'; + +/// A [WorkoutSummary] object store vary metrics of a workout. +/// +/// * [workoutType] - The type of workout. See [HealthWorkoutActivityType] for available types. +/// * [totalDistance] - The total distance that was traveled during a workout. +/// * [totalEnergyBurned] - The amount of energy that was burned during a workout. +/// * [totalSteps] - The number of steps during a workout. +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) +class WorkoutSummary { + /// Workout type. + String workoutType; + + /// The total distance value of the workout. + num totalDistance; + + /// The total energy burned value of the workout. + num totalEnergyBurned; + + /// The total steps value of the workout. + num totalSteps; + + WorkoutSummary({ + required this.workoutType, + required this.totalDistance, + required this.totalEnergyBurned, + required this.totalSteps, + }); + + /// Create a [WorkoutSummary] based on a health data point from native data format. + factory WorkoutSummary.fromHealthDataPoint(dynamic dataPoint) => + WorkoutSummary( + workoutType: dataPoint['workout_type'] as String? ?? '', + totalDistance: dataPoint['total_distance'] as num? ?? 0, + totalEnergyBurned: dataPoint['total_energy_burned'] as num? ?? 0, + totalSteps: dataPoint['total_steps'] as num? ?? 0, + ); + + /// Create a [HealthDataPoint] from json. + factory WorkoutSummary.fromJson(Map json) => + _$WorkoutSummaryFromJson(json); + + /// Convert this [HealthDataPoint] to json. + Map toJson() => _$WorkoutSummaryToJson(this); + + @override + String toString() => '$runtimeType - ' + 'workoutType: $workoutType' + 'totalDistance: $totalDistance, ' + 'totalEnergyBurned: $totalEnergyBurned, ' + 'totalSteps: $totalSteps'; +} diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 808c0ec15..cc118c503 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -1,23 +1,31 @@ name: health description: Wrapper for HealthKit on iOS and Google Fit and Health Connect on Android. -version: 9.0.0 +version: 10.2.0 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: - sdk: ">=2.17.0 <4.0.0" - flutter: ">=3.0.0" + sdk: ">=3.2.0 <4.0.0" + flutter: ">=3.6.0" dependencies: flutter: sdk: flutter - intl: ^0.19.0 - device_info_plus: ^9.0.0 + intl: '>=0.18.0 <0.20.0' + device_info_plus: '>=9.0.0 <11.0.0' + json_annotation: ^4.8.0 + carp_serializable: ^1.1.0 # polymorphic json serialization dev_dependencies: flutter_test: sdk: flutter integration_test: sdk: flutter + flutter_lints: any + + # Using carp_serializable & json_serializable to auto generate json code (.g files) with this command: + # dart run build_runner build --delete-conflicting-outputs + build_runner: any + json_serializable: any flutter: plugin: