From dd6a32a5af51ffee1c4b2ef0f3e932bb6ed14914 Mon Sep 17 00:00:00 2001 From: bruce3x Date: Tue, 26 Dec 2023 17:10:13 +0800 Subject: [PATCH 01/24] feat: add `STAND_TIME` data type --- packages/health/README.md | 7 ++++--- packages/health/ios/Classes/SwiftHealthPlugin.swift | 4 +++- packages/health/lib/src/data_types.dart | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/health/README.md b/packages/health/README.md index f3d47d36a..c2b583427 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -23,7 +23,7 @@ Note that for Android, the target phone **needs** to have [Google Fit](https://w ## 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 | | @@ -37,8 +37,8 @@ Note that for Android, the target phone **needs** to have [Google Fit](https://w | 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 | | | +| 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 | | | | @@ -71,6 +71,7 @@ Note that for Android, the target phone **needs** to have [Google Fit](https://w | AUDIOGRAM | DECIBEL_HEARING_LEVEL | yes | | | | | ELECTROCARDIOGRAM | VOLT | yes | | | Requires Apple Watch to write the data | | NUTRITION | NO_UNIT | yes | yes | yes | | +| STAND_TIME | MINUTES | yes | | | | ## Setup diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 36d560010..89f18882a 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -62,7 +62,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let HEADACHE_SEVERE = "HEADACHE_SEVERE" let ELECTROCARDIOGRAM = "ELECTROCARDIOGRAM" let NUTRITION = "NUTRITION" - + let STAND_TIME = "STAND_TIME" + // 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 @@ -1052,6 +1053,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { dataTypesDict[WORKOUT] = HKSampleType.workoutType() dataTypesDict[NUTRITION] = HKSampleType.correlationType( forIdentifier: .food)! + dataTypesDict[STAND_TIME] = HKSampleType.quantityType(forIdentifier: .appleStandTime) healthDataTypes = Array(dataTypesDict.values) } diff --git a/packages/health/lib/src/data_types.dart b/packages/health/lib/src/data_types.dart index bca127cd0..1c2372834 100644 --- a/packages/health/lib/src/data_types.dart +++ b/packages/health/lib/src/data_types.dart @@ -49,6 +49,7 @@ enum HealthDataType { HEADACHE_SEVERE, HEADACHE_UNSPECIFIED, NUTRITION, + STAND_TIME, // Heart Rate events (specific to Apple Watch) HIGH_HEART_RATE_EVENT, @@ -114,6 +115,7 @@ const List _dataTypeKeysIOS = [ HealthDataType.HEADACHE_UNSPECIFIED, HealthDataType.ELECTROCARDIOGRAM, HealthDataType.NUTRITION, + HealthDataType.STAND_TIME, ]; /// List of data types available on Android @@ -208,6 +210,7 @@ const Map _dataTypeToUnit = { HealthDataType.ELECTROCARDIOGRAM: HealthDataUnit.VOLT, HealthDataType.NUTRITION: HealthDataUnit.NO_UNIT, + HealthDataType.STAND_TIME: HealthDataUnit.MINUTE, }; const PlatformTypeJsonValue = { From b6978abbba4ab6cf54a94ec34ebc4f8348d6bb50 Mon Sep 17 00:00:00 2001 From: bruce3x Date: Mon, 8 Jan 2024 18:56:52 +0800 Subject: [PATCH 02/24] feat: get data only from watch --- packages/health/ios/Classes/SwiftHealthPlugin.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 89f18882a..280e5e3dd 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -582,10 +582,13 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let predicate = HKQuery.predicateForSamples( withStart: dateFrom, end: dateTo, options: .strictStartDate) + let watchPredicate = HKQuery.predicateForObjects(withDeviceProperty: HKDevicePropertyKeyModel, allowedValues: ["Watch"]) + let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, watchPredicate]) + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) let query = HKSampleQuery( - sampleType: dataType, predicate: predicate, limit: limit, sortDescriptors: [sortDescriptor] + sampleType: dataType, predicate: combinedPredicate, limit: limit, sortDescriptors: [sortDescriptor] ) { [self] x, samplesOrNil, error in From 23063455a84941b872fb56fd4bf440b85ecd6655 Mon Sep 17 00:00:00 2001 From: ikoamu Date: Sat, 24 Feb 2024 18:13:55 +0900 Subject: [PATCH 03/24] Add support for saving blood pressure as a correlation(ios) --- packages/health/ios/Classes/SwiftHealthPlugin.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 36d560010..070968760 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -404,9 +404,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)") From 42f7eef7ceef85adb6e55d6627431c58155b4bd6 Mon Sep 17 00:00:00 2001 From: tm-hirai Date: Thu, 14 Mar 2024 21:46:29 +0900 Subject: [PATCH 04/24] add body water mass to health connect --- packages/health/README.md | 1 + .../cachet/plugins/health/HealthPlugin.kt | 18 ++++++++++++++++++ packages/health/lib/src/data_types.dart | 3 +++ 3 files changed, 22 insertions(+) diff --git a/packages/health/README.md b/packages/health/README.md index 800e245fc..7d6a63ca2 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -33,6 +33,7 @@ Note that for Android, the target phone **needs** to have [Google Fit](https://w | 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 | | 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..77014f78d 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 @@ -85,6 +85,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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" @@ -1940,6 +1941,16 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), ) + 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, @@ -2122,6 +2133,12 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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), @@ -2406,6 +2423,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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, diff --git a/packages/health/lib/src/data_types.dart b/packages/health/lib/src/data_types.dart index bca127cd0..ffda58380 100644 --- a/packages/health/lib/src/data_types.dart +++ b/packages/health/lib/src/data_types.dart @@ -12,6 +12,7 @@ enum HealthDataType { BODY_FAT_PERCENTAGE, BODY_MASS_INDEX, BODY_TEMPERATURE, + BODY_WATER_MASS, DIETARY_CARBS_CONSUMED, DIETARY_ENERGY_CONSUMED, DIETARY_FATS_CONSUMED, @@ -126,6 +127,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, @@ -160,6 +162,7 @@ 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_ENERGY_CONSUMED: HealthDataUnit.KILOCALORIE, HealthDataType.DIETARY_FATS_CONSUMED: HealthDataUnit.GRAM, From 70afaed511a8fc7b5d59075971958dbb20cdbf77 Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 19 Mar 2024 21:51:37 +1100 Subject: [PATCH 05/24] [Health] Add workout summary, manual entry and new health data types --- packages/health/CHANGELOG.md | 1 - packages/health/android/build.gradle | 2 +- .../cachet/plugins/health/HealthPlugin.kt | 314 +++++++++- packages/health/example/lib/main.dart | 6 + packages/health/example/lib/util.dart | 2 + .../ios/Classes/SwiftHealthPlugin.swift | 556 ++++++++++++++---- packages/health/lib/health.dart | 1 + packages/health/lib/src/data_types.dart | 23 + .../health/lib/src/health_data_point.dart | 68 ++- packages/health/lib/src/health_factory.dart | 247 +++++++- .../health/lib/src/health_value_types.dart | 39 +- packages/health/lib/src/workout_summary.dart | 54 ++ 12 files changed, 1129 insertions(+), 184 deletions(-) create mode 100644 packages/health/lib/src/workout_summary.dart diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 0824fec32..8f14d1b16 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -23,7 +23,6 @@ - Added respiratory rate and peripheral perfusion index to HealthConnect - Minor fixes to requestAuthorization, sleep stage filtering - ## 7.0.1 - Updated dart doc 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..3c15984b2 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 @@ -20,11 +20,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 @@ -114,6 +116,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : private var SNACK = "SNACK" private var MEAL_UNKNOWN = "UNKNOWN" + private var TOTAL_CALORIES_BURNED = "TOTAL_CALORIES_BURNED" val workoutTypeMap = mapOf( "AEROBICS" to FitnessActivities.AEROBICS, @@ -417,7 +420,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : return false } - private fun onHealthConnectPermissionCallback(permissionGranted: Set) { if(permissionGranted.isEmpty()) { @@ -430,7 +432,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } - + private fun keyToHealthDataType(type: String): DataType { return when (type) { BODY_FAT_PERCENTAGE -> DataType.TYPE_BODY_FAT_PERCENTAGE @@ -1046,6 +1048,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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) @@ -1124,7 +1127,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ) .addOnSuccessListener( threadPoolExecutor!!, - dataHandler(dataType, field, result), + dataHandler(dataType, field, includeManualEntry, result), ) .addOnFailureListener( errHandler( @@ -1136,12 +1139,92 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } - private fun dataHandler(dataType: DataType, field: Field, result: Result) = + private fun getIntervalData(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + getAggregateHCData(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 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) + } + val fitnessOptions = typesBuilder.build() + val googleSignInAccount = GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) + + 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!")) + } + + private fun getAggregateData(call: MethodCall, result: Result) { + if (context == null) { + result.success(null) + return + } + + 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")!! + + 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) + } + + Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) + .readData(readWorkoutsRequest.build()) + .addOnSuccessListener (threadPoolExecutor!!, aggregateDataHandler(includeManualEntry, result)) + .addOnFailureListener(errHandler(result, "There was an error getting the aggregate data!")) + } + + 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 = dataSet.dataPoints.mapIndexed { _, dataPoint -> + val healthData = dataPoints.mapIndexed { _, dataPoint -> return@mapIndexed hashMapOf( "value" to getHealthDataValue(dataPoint, field), "date_from" to dataPoint.getStartTime(TimeUnit.MILLISECONDS), @@ -1266,6 +1349,99 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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() @@ -1544,6 +1720,27 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } + /// 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) + } + } + private fun getActivityType(type: String): String { return workoutTypeMap[type] ?: FitnessActivities.UNKNOWN } @@ -1558,13 +1755,16 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "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() } } @@ -1721,7 +1921,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } } - if(healthConnectRequestPermissionsLauncher == null) { result.success(false) Log.i("FLUTTER_HEALTH", "Permission launcher not found") @@ -1747,10 +1946,10 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // 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) @@ -1799,6 +1998,17 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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( @@ -1812,6 +2022,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "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(), @@ -1866,6 +2078,50 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ); } + 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) } + } + } + // TODO: Find alternative to SOURCE_ID or make it nullable? fun convertRecord(record: Any, dataType: String): List> { val metadata = (record as Record).metadata @@ -1990,6 +2246,24 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), ) + 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(), @@ -1997,6 +2271,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "value" to ChronoUnit.MINUTES.between(record.startTime, record.endTime), "source_id" to "", "source_name" to metadata.dataOrigin.packageName, + "stage" to if (record.stages.isNotEmpty()) record.stages[0] else SleepSessionRecord.STAGE_TYPE_UNKNOWN, ), ) is RestingHeartRateRecord -> return listOf( @@ -2224,6 +2499,13 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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 ") @@ -2425,6 +2707,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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, @@ -2464,4 +2747,19 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // "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/lib/main.dart b/packages/health/example/lib/main.dart index 6825e3da0..baf1d2a5b 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -162,6 +162,12 @@ class _HealthAppState extends State { totalDistance: 2430, totalEnergyBurned: 400); success &= await health.writeBloodPressure(90, 80, earlier, now); + success &= await health.writeHealthData( + 0.0, HealthDataType.SLEEP_REM, earlier, now); + success &= await health.writeHealthData( + 0.0, HealthDataType.SLEEP_ASLEEP, earlier, now); + success &= await health.writeHealthData( + 0.0, HealthDataType.SLEEP_AWAKE, earlier, now); success &= await health.writeHealthData( 0.0, HealthDataType.SLEEP_DEEP, earlier, now); success &= await health.writeMeal( diff --git a/packages/health/example/lib/util.dart b/packages/health/example/lib/util.dart index bef195969..d22aec03d 100644 --- a/packages/health/example/lib/util.dart +++ b/packages/health/example/lib/util.dart @@ -87,4 +87,6 @@ const List dataTypesAndroid = [ HealthDataType.RESTING_HEART_RATE, HealthDataType.FLIGHTS_CLIMBED, HealthDataType.NUTRITION, + + HealthDataType.TOTAL_CALORIES_BURNED, ]; diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 36d560010..737b5a64b 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" @@ -44,15 +46,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" @@ -62,7 +69,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 @@ -114,22 +121,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) @@ -137,58 +144,69 @@ 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) } - + /// Handle writeWorkoutData 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], @@ -197,12 +215,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) @@ -212,7 +230,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { types.append(DIETARY_FATS_CONSUMED) permissions.append(nutritionPermission) } - + for (index, type) in types.enumerated() { let sampleType = dataTypeLookUp(key: type) let success = hasPermission(type: sampleType, access: permissions[index]) @@ -221,12 +239,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 { @@ -241,7 +259,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], @@ -250,7 +268,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { else { throw PluginError(message: "Invalid Arguments!") } - + var typesToRead = Set() var typesToWrite = Set() for (index, key) in types.enumerated() { @@ -259,7 +277,7 @@ 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) - + typesToWrite.insert(caloriesType); typesToWrite.insert(carbsType); typesToWrite.insert(proteinType); @@ -278,7 +296,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } } } - + if #available(iOS 13.0, *) { healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { (success, error) in @@ -290,7 +308,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), @@ -301,12 +319,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, @@ -317,7 +335,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 @@ -329,7 +347,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]), @@ -340,12 +358,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() @@ -355,23 +373,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 @@ -383,7 +401,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), @@ -395,7 +413,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), @@ -404,7 +422,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { type: HKSampleType.quantityType(forIdentifier: .bloodPressureDiastolic)!, quantity: HKQuantity(unit: HKUnit.millimeterOfMercury(), doubleValue: diastolic), start: dateFrom, end: dateTo) - + HKHealthStore().save( [systolic_sample, diastolic_sample], withCompletion: { (success, error) in @@ -416,7 +434,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,37 +450,37 @@ 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(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 #available(iOS 15.0, *){ 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)") @@ -475,7 +493,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { result(false) } } - + func writeWorkoutData(call: FlutterMethodCall, result: @escaping FlutterResult) throws { guard let arguments = call.arguments as? NSDictionary, let activityType = (arguments["activityType"] as? String), @@ -485,10 +503,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( @@ -498,17 +516,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 @@ -520,33 +538,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 { @@ -557,10 +575,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)! @@ -568,27 +586,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 @@ -599,17 +622,27 @@ 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(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 } } @@ -645,14 +678,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)", @@ -667,13 +701,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]() @@ -700,15 +738,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 @@ -728,8 +766,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } } } - - + + let dictionaries = nutritionSample.map { sample -> NSDictionary in return [ "uuid": "\(sample.uuid)", @@ -748,7 +786,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) @@ -763,10 +801,10 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { } } } - + HKHealthStore().execute(query) } - + @available(iOS 14.0, *) private func fetchEcgMeasurements(_ sample: HKElectrocardiogram) -> NSDictionary { let semaphore = DispatchSemaphore(value: 0) @@ -800,66 +838,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() @@ -908,7 +1033,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 @@ -991,7 +1116,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( @@ -1004,7 +1129,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( @@ -1039,22 +1164,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( @@ -1063,40 +1228,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..6fe96a50a 100644 --- a/packages/health/lib/health.dart +++ b/packages/health/lib/health.dart @@ -13,3 +13,4 @@ part 'src/functions.dart'; part 'src/health_data_point.dart'; part 'src/health_value_types.dart'; part 'src/health_factory.dart'; +part 'src/workout_summary.dart'; diff --git a/packages/health/lib/src/data_types.dart b/packages/health/lib/src/data_types.dart index bca127cd0..4d2e30747 100644 --- a/packages/health/lib/src/data_types.dart +++ b/packages/health/lib/src/data_types.dart @@ -28,6 +28,8 @@ enum HealthDataType { WALKING_HEART_RATE, WEIGHT, DISTANCE_WALKING_RUNNING, + DISTANCE_SWIMMING, + DISTANCE_CYCLING, FLIGHTS_CLIMBED, MOVE_MINUTES, DISTANCE_DELTA, @@ -35,6 +37,9 @@ enum HealthDataType { WATER, SLEEP_IN_BED, SLEEP_ASLEEP, + SLEEP_ASLEEP_CORE, + SLEEP_ASLEEP_DEEP, + SLEEP_ASLEEP_REM, SLEEP_AWAKE, SLEEP_LIGHT, SLEEP_DEEP, @@ -56,6 +61,9 @@ enum HealthDataType { IRREGULAR_HEART_RATE_EVENT, ELECTRODERMAL_ACTIVITY, ELECTROCARDIOGRAM, + + // Health Connect + TOTAL_CALORIES_BURNED } /// Access types for Health Data. @@ -98,12 +106,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, @@ -134,6 +147,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, @@ -146,6 +160,7 @@ const List _dataTypeKeysAndroid = [ HealthDataType.BASAL_ENERGY_BURNED, HealthDataType.RESPIRATORY_RATE, HealthDataType.NUTRITION, + HealthDataType.TOTAL_CALORIES_BURNED, ]; /// Maps a [HealthDataType] to a [HealthDataUnit]. @@ -176,6 +191,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, @@ -183,6 +200,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, @@ -208,6 +228,9 @@ const Map _dataTypeToUnit = { HealthDataType.ELECTROCARDIOGRAM: HealthDataUnit.VOLT, HealthDataType.NUTRITION: HealthDataUnit.NO_UNIT, + + // Health Connect + HealthDataType.TOTAL_CALORIES_BURNED: HealthDataUnit.KILOCALORIE, }; const PlatformTypeJsonValue = { diff --git a/packages/health/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index e6c916467..1b0e7dac2 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -12,17 +12,22 @@ class HealthDataPoint { String _deviceId; String _sourceId; String _sourceName; + bool? _isManualEntry; + WorkoutSummary? _workoutSummary; HealthDataPoint( - this._value, - this._type, - this._unit, - this._dateFrom, - this._dateTo, - this._platform, - this._deviceId, - this._sourceId, - this._sourceName) { + this._value, + this._type, + this._unit, + this._dateFrom, + this._dateTo, + this._platform, + this._deviceId, + this._sourceId, + this._sourceName, + this._isManualEntry, + this._workoutSummary, + ) { // set the value to minutes rather than the category // returned by the native API if (type == HealthDataType.MINDFULNESS || @@ -60,19 +65,21 @@ class HealthDataPoint { } 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']); + 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['is_manual_entry'], + WorkoutSummary.fromJson(json['workout_summary']), + ); } /// Converts the [HealthDataPoint] to a json object @@ -85,7 +92,9 @@ class HealthDataPoint { 'platform_type': PlatformTypeJsonValue[platform], 'device_id': deviceId, 'source_id': sourceId, - 'source_name': sourceName + 'source_name': sourceName, + 'is_manual_entry': isManualEntry, + 'workout_summary': workoutSummary?.toJson(), }; @override @@ -98,7 +107,9 @@ class HealthDataPoint { platform: $platform, deviceId: $deviceId, sourceId: $sourceId, - sourceName: $sourceName"""; + sourceName: $sourceName + isManualEntry: $isManualEntry + workoutSummary: ${workoutSummary?.toString()}"""; /// The quantity value of the data point HealthValue get value => _value; @@ -133,6 +144,12 @@ class HealthDataPoint { /// The name of the source from which the data point was fetched. String get sourceName => _sourceName; + /// The user entered state of the data point. + bool? get isManualEntry => _isManualEntry; + + /// The summary of the workout data point. + WorkoutSummary? get workoutSummary => _workoutSummary; + @override bool operator ==(Object o) { return o is HealthDataPoint && @@ -144,7 +161,8 @@ class HealthDataPoint { this.platform == o.platform && this.deviceId == o.deviceId && this.sourceId == o.sourceId && - this.sourceName == o.sourceName; + this.sourceName == o.sourceName && + this.isManualEntry == o.isManualEntry; } @override diff --git a/packages/health/lib/src/health_factory.dart b/packages/health/lib/src/health_factory.dart index dabd56b1b..0ab78c855 100644 --- a/packages/health/lib/src/health_factory.dart +++ b/packages/health/lib/src/health_factory.dart @@ -95,6 +95,33 @@ class HealthFactory { } } + /// Disconnect Google fit. + /// + /// Not supported on iOS and 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 (_platformType == PlatformType.ANDROID) _handleBMI(mTypes, mPermissions); + + List keys = mTypes.map((dataType) => dataType.name).toList(); + + return await _channel.invokeMethod( + 'disconnect', {'types': keys, "permissions": mPermissions}); + } + /// Requests permissions to access data types in Apple Health or Google Fit. /// /// Returns true if successful, false otherwise @@ -178,16 +205,16 @@ 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(); @@ -201,15 +228,18 @@ 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!, - '', - ''); + NumericHealthValue(bmiValue), + dataType, + unit, + weights[i].dateFrom, + weights[i].dateTo, + _platformType, + _deviceId!, + '', + '', + !includeManualEntry, + null, + ); bmiHealthPoints.add(x); } @@ -263,6 +293,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 || @@ -457,11 +490,13 @@ class HealthFactory { /// Fetch a list of health data points based on [types]. Future> getHealthDataFromTypes( - DateTime startTime, DateTime endTime, List types) async { + DateTime startTime, DateTime endTime, List types, + {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); } @@ -473,9 +508,43 @@ 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( + DateTime startDate, + DateTime endDate, + List types, + 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( + DateTime startDate, DateTime endDate, List types, + {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 ? (await _deviceInfo.androidInfo).id @@ -490,19 +559,65 @@ class HealthFactory { // If BodyMassIndex is requested on Android, calculate this manually if (dataType == HealthDataType.BODY_MASS_INDEX && _platformType == PlatformType.ANDROID) { - return _computeAndroidBMI(startTime, endTime); + return _computeAndroidBMI(startTime, endTime, includeManualEntry); } - return await _dataQuery(startTime, endTime, dataType); + 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 ??= _platformType == PlatformType.ANDROID + ? (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 ??= _platformType == PlatformType.ANDROID + ? (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 _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, 'startTime': startTime.millisecondsSinceEpoch, - 'endTime': endTime.millisecondsSinceEpoch + 'endTime': endTime.millisecondsSinceEpoch, + 'includeManualEntry': includeManualEntry }; final fetchedDataPoints = await _channel.invokeMethod('getData', args); @@ -524,7 +639,63 @@ class HealthFactory { } } - /// Parses the fetched data points into a list of [HealthDataPoint]. + /// 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 mesg = { + "dataType": dataType, + "dataPoints": fetchedDataPoints, + "deviceId": _deviceId!, + }; + return _parse(mesg); + } + 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); + if (fetchedDataPoints != null) { + final mesg = { + "dataType": HealthDataType.WORKOUT, + "dataPoints": fetchedDataPoints, + "deviceId": _deviceId!, + }; + return _parse(mesg); + } + return []; + } + static List _parse(Map message) { final dataType = message["dataType"]; final dataPoints = message["dataPoints"]; @@ -535,19 +706,35 @@ class HealthFactory { HealthValue value; if (dataType == HealthDataType.AUDIOGRAM) { value = AudiogramHealthValue.fromJson(e); - } else if (dataType == HealthDataType.WORKOUT) { + } else if (dataType == HealthDataType.WORKOUT && + e["totalEnergyBurned"] != null) { value = WorkoutHealthValue.fromJson(e); } else if (dataType == HealthDataType.ELECTROCARDIOGRAM) { value = ElectrocardiogramHealthValue.fromJson(e); } else if (dataType == HealthDataType.NUTRITION) { value = NutritionHealthValue.fromJson(e); } else { - value = NumericHealthValue(e['value']); + value = NumericHealthValue(e['value'] ?? 0); } 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 bool? isManualEntry = e["is_manual_entry"]; + + // Set WorkoutSummary + WorkoutSummary? workoutSummary; + if (e["workout_type"] != null || + e["total_distance"] != null || + e["total_energy_burned"] != null || + e["total_steps"] != null) { + workoutSummary = WorkoutSummary( + e["workout_type"] ?? '', + e["total_distance"] ?? 0, + e["total_energy_burned"] ?? 0, + e["total_steps"] ?? 0, + ); + } return HealthDataPoint( value, dataType, @@ -558,6 +745,8 @@ class HealthFactory { device, sourceId, sourceName, + isManualEntry, + workoutSummary, ); }).toList(); @@ -602,6 +791,12 @@ class HealthFactory { return 4; case HealthDataType.SLEEP_REM: return 5; + case HealthDataType.SLEEP_ASLEEP_CORE: + return 3; + case HealthDataType.SLEEP_ASLEEP_DEEP: + return 4; + case HealthDataType.SLEEP_ASLEEP_REM: + return 5; case HealthDataType.HEADACHE_UNSPECIFIED: return 0; case HealthDataType.HEADACHE_NOT_PRESENT: diff --git a/packages/health/lib/src/health_value_types.dart b/packages/health/lib/src/health_value_types.dart index ad2adef85..31e6ed697 100644 --- a/packages/health/lib/src/health_value_types.dart +++ b/packages/health/lib/src/health_value_types.dart @@ -107,13 +107,17 @@ class WorkoutHealthValue extends HealthValue { HealthDataUnit? _totalEnergyBurnedUnit; int? _totalDistance; HealthDataUnit? _totalDistanceUnit; + int? _totalSteps; + HealthDataUnit? _totalStepsUnit; WorkoutHealthValue( this._workoutActivityType, this._totalEnergyBurned, this._totalEnergyBurnedUnit, this._totalDistance, - this._totalDistanceUnit); + this._totalDistanceUnit, + this._totalSteps, + this._totalStepsUnit); /// The type of the workout. HealthWorkoutActivityType get workoutActivityType => _workoutActivityType; @@ -134,6 +138,14 @@ class WorkoutHealthValue extends HealthValue { /// Might not be available for all workouts. HealthDataUnit? get totalDistanceUnit => _totalDistanceUnit; + /// The total steps covered during the workout. + /// Might not be available for all workouts. + int? get totalSteps => _totalSteps; + + /// The unit of the total steps covered during the workout. + /// Might not be available for all workouts. + HealthDataUnit? get totalStepsUnit => _totalStepsUnit; + factory WorkoutHealthValue.fromJson(json) { return WorkoutHealthValue( HealthWorkoutActivityType.values.firstWhere( @@ -151,6 +163,11 @@ class WorkoutHealthValue extends HealthValue { json['totalDistanceUnit'] != null ? HealthDataUnit.values.firstWhere( (element) => element.name == json['totalDistanceUnit']) + : null, + json['totalSteps'] != null ? (json['totalSteps'] as num).toInt() : null, + json['totalStepsUnit'] != null + ? HealthDataUnit.values + .firstWhere((element) => element.name == json['totalStepsUnit']) : null); } @@ -161,6 +178,8 @@ class WorkoutHealthValue extends HealthValue { 'totalEnergyBurnedUnit': _totalEnergyBurnedUnit?.name, 'totalDistance': _totalDistance, 'totalDistanceUnit': _totalDistanceUnit?.name, + 'totalSteps': _totalSteps, + 'totalStepsUnit': _totalStepsUnit?.name, }; @override @@ -169,7 +188,9 @@ class WorkoutHealthValue extends HealthValue { totalEnergyBurned: $totalEnergyBurned, totalEnergyBurnedUnit: ${totalEnergyBurnedUnit?.name}, totalDistance: $totalDistance, - totalDistanceUnit: ${totalDistanceUnit?.name}"""; + totalDistanceUnit: ${totalDistanceUnit?.name} + totalSteps: $totalSteps, + totalStepsUnit: ${totalStepsUnit?.name}"""; } @override @@ -179,12 +200,20 @@ class WorkoutHealthValue extends HealthValue { this.totalEnergyBurned == o.totalEnergyBurned && this.totalEnergyBurnedUnit == o.totalEnergyBurnedUnit && this.totalDistance == o.totalDistance && - this.totalDistanceUnit == o.totalDistanceUnit; + this.totalDistanceUnit == o.totalDistanceUnit && + this.totalSteps == o.totalSteps && + this.totalStepsUnit == o.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 diff --git a/packages/health/lib/src/workout_summary.dart b/packages/health/lib/src/workout_summary.dart new file mode 100644 index 000000000..5d04521b2 --- /dev/null +++ b/packages/health/lib/src/workout_summary.dart @@ -0,0 +1,54 @@ +part of health; + +/// A [WorkoutSummary] object store vary metrics of a workout. +/// * totalDistance - The total distance that was traveled during a workout. +/// * totalEnergyBurned - The amount of energy that was burned during a workout. +/// * totalSteps - The count of steps was burned during a workout. +class WorkoutSummary { + String _workoutType; + num _totalDistance; + num _totalEnergyBurned; + num _totalSteps; + + WorkoutSummary( + this._workoutType, + this._totalDistance, + this._totalEnergyBurned, + this._totalSteps, + ); + + /// Converts a json object to the [WorkoutSummary] + factory WorkoutSummary.fromJson(json) => WorkoutSummary( + json['workoutType'], + json['totalDistance'], + json['totalEnergyBurned'], + json['totalSteps'], + ); + + /// Converts the [WorkoutSummary] to a json object + Map toJson() => { + 'workoutType': workoutType, + 'totalDistance': totalDistance, + 'totalEnergyBurned': totalEnergyBurned, + 'totalSteps': totalSteps + }; + + @override + String toString() => '${this.runtimeType} - ' + 'workoutType: $workoutType' + 'totalDistance: $totalDistance, ' + 'totalEnergyBurned: $totalEnergyBurned, ' + 'totalSteps: $totalSteps'; + + /// Workout type. + String get workoutType => _workoutType; + + /// The total distance value of the workout. + num get totalDistance => _totalDistance; + + /// The total energy burned value of the workout. + num get totalEnergyBurned => _totalEnergyBurned; + + /// The total steps value of the workout. + num get totalSteps => _totalSteps; +} From 2c39ccb785f86e6b48ea2e190980d1f38706b9db Mon Sep 17 00:00:00 2001 From: Shubham Singh Date: Mon, 25 Mar 2024 15:36:03 +0530 Subject: [PATCH 06/24] Add iOS support for dietaryCaffeine --- packages/health/example/lib/main.dart | 2 +- packages/health/example/lib/util.dart | 1 + .../health/ios/Classes/SwiftHealthPlugin.swift | 15 ++++++++++++++- packages/health/lib/src/data_types.dart | 3 +++ packages/health/lib/src/health_factory.dart | 2 ++ packages/health/lib/src/health_value_types.dart | 14 ++++++++++++-- 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 6825e3da0..7e964d781 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -165,7 +165,7 @@ class _HealthAppState extends State { success &= await health.writeHealthData( 0.0, HealthDataType.SLEEP_DEEP, earlier, now); success &= await health.writeMeal( - earlier, now, 1000, 50, 25, 50, "Banana", MealType.SNACK); + earlier, now, 1000, 50, 25, 50, "Banana", 0.002, MealType.SNACK); // Store an Audiogram // Uncomment these on iOS - only available on iOS // const frequencies = [125.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0]; diff --git a/packages/health/example/lib/util.dart b/packages/health/example/lib/util.dart index bef195969..9ec291fec 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, diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 36d560010..88ed6bfee 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -28,6 +28,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" @@ -211,6 +212,8 @@ 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() { @@ -259,11 +262,13 @@ 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] @@ -426,6 +431,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") @@ -459,6 +465,11 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { 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 meal = HKCorrelation.init(type: HKCorrelationType.init(HKCorrelationTypeIdentifier.food), start: dateFrom, end: dateTo, objects: nutrition, metadata: metadata) @@ -1017,6 +1028,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( diff --git a/packages/health/lib/src/data_types.dart b/packages/health/lib/src/data_types.dart index bca127cd0..173d70b1a 100644 --- a/packages/health/lib/src/data_types.dart +++ b/packages/health/lib/src/data_types.dart @@ -13,6 +13,7 @@ enum HealthDataType { BODY_MASS_INDEX, BODY_TEMPERATURE, DIETARY_CARBS_CONSUMED, + DIETARY_CAFFEINE, DIETARY_ENERGY_CONSUMED, DIETARY_FATS_CONSUMED, DIETARY_PROTEIN_CONSUMED, @@ -78,6 +79,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, @@ -161,6 +163,7 @@ const Map _dataTypeToUnit = { HealthDataType.BODY_MASS_INDEX: HealthDataUnit.NO_UNIT, HealthDataType.BODY_TEMPERATURE: HealthDataUnit.DEGREE_CELSIUS, 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, diff --git a/packages/health/lib/src/health_factory.dart b/packages/health/lib/src/health_factory.dart index dabd56b1b..d4a7b54d5 100644 --- a/packages/health/lib/src/health_factory.dart +++ b/packages/health/lib/src/health_factory.dart @@ -390,6 +390,7 @@ class HealthFactory { double? protein, double? fatTotal, String? name, + double? caffeine, MealType mealType) async { if (startTime.isAfter(endTime)) throw ArgumentError("startTime must be equal or earlier than endTime"); @@ -402,6 +403,7 @@ class HealthFactory { 'protein': protein, 'fatTotal': fatTotal, 'name': name, + 'caffeine': caffeine, 'mealType': mealType.name, }; bool? success = await _channel.invokeMethod('writeMeal', args); diff --git a/packages/health/lib/src/health_value_types.dart b/packages/health/lib/src/health_value_types.dart index ad2adef85..6c16a80a5 100644 --- a/packages/health/lib/src/health_value_types.dart +++ b/packages/health/lib/src/health_value_types.dart @@ -290,6 +290,7 @@ class ElectrocardiogramVoltageValue extends HealthValue { /// * [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 class NutritionHealthValue extends HealthValue { double? _protein; @@ -297,10 +298,11 @@ class NutritionHealthValue extends HealthValue { double? _fat; String? _name; double? _carbs; + double? _caffeine; String _mealType; NutritionHealthValue(this._protein, this._calories, this._fat, this._name, - this._carbs, this._mealType); + this._carbs, this._caffeine, this._mealType); /// The amount of protein in grams. double? get protein => _protein; @@ -317,6 +319,9 @@ class NutritionHealthValue extends HealthValue { /// The amount of carbs in grams. double? get carbs => _carbs; + /// The amount of caffeine in grams. + double? get caffeine => _caffeine; + /// The type of meal. String get mealType => _mealType; @@ -327,6 +332,7 @@ class NutritionHealthValue extends HealthValue { 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['caffeine'] != null ? (json['caffeine'] as num).toDouble() : null, json['mealType'] as String, ); } @@ -338,6 +344,7 @@ class NutritionHealthValue extends HealthValue { 'fat': _fat, 'name': _name, 'carbs': _carbs, + 'caffeine': _caffeine, 'mealType': _mealType, }; @@ -348,6 +355,7 @@ class NutritionHealthValue extends HealthValue { fat: ${fat.toString()}, name: ${name.toString()}, carbs: ${carbs.toString()}, + caffeine: ${caffeine.toString()}, mealType: $mealType"""; } @@ -359,11 +367,13 @@ class NutritionHealthValue extends HealthValue { o.fat == this.fat && o.name == this.name && o.carbs == this.carbs && + o.caffeine == this.caffeine && o.mealType == this.mealType; } @override - int get hashCode => Object.hash(protein, calories, fat, name, carbs); + int get hashCode => + Object.hash(protein, calories, fat, name, carbs, caffeine); } /// An abstract class for health values. From 6254f87723fa2143d866f240d0c8063497bd8cdc Mon Sep 17 00:00:00 2001 From: Shubham Singh Date: Mon, 25 Mar 2024 16:26:22 +0530 Subject: [PATCH 07/24] Add Android support for caffeine --- .../src/main/kotlin/cachet/plugins/health/HealthPlugin.kt | 2 ++ 1 file changed, 2 insertions(+) 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..a41a7ed22 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 @@ -623,6 +623,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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")!! @@ -636,6 +637,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : totalCarbohydrate = carbs?.grams, protein = protein?.grams, totalFat = fat?.grams, + caffeine = caffeine?.grams, startTime = startTime, startZoneOffset = null, endTime = endTime, From 0a633f1055c1933ea61d870ae0f28d9afdc4050a Mon Sep 17 00:00:00 2001 From: bardram Date: Wed, 27 Mar 2024 13:59:29 +0100 Subject: [PATCH 08/24] misc. clean up in example app + docs in health factory --- .../ios/Flutter/AppFrameworkInfo.plist | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- packages/health/example/lib/main.dart | 256 +++++++++--------- packages/health/example/lib/util.dart | 1 - packages/health/example/pubspec.yaml | 5 +- packages/health/lib/src/health_factory.dart | 115 ++++---- packages/health/pubspec.yaml | 4 +- 8 files changed, 203 insertions(+), 184 deletions(-) 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 @@ + const JsonEncoder.withIndent(' ').convert(object); + void main() => runApp(HealthApp()); class HealthApp extends StatefulWidget { @@ -84,7 +89,7 @@ class _HealthAppState extends State { authorized = await health.requestAuthorization(types, permissions: permissions); } catch (error) { - print("Exception in authorize: $error"); + debugPrint("Exception in authorize: $error"); } } @@ -107,19 +112,20 @@ class _HealthAppState extends State { // fetch health data List healthData = await health.getHealthDataFromTypes(yesterday, now, types); + + debugPrint( + 'Total number of data points: ${healthData.length}. 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); - // print the results - _healthDataList.forEach((x) => print(x)); - // update the UI to display the results setState(() { _state = _healthDataList.isEmpty ? AppState.NO_DATA : AppState.DATA_READY; @@ -172,6 +178,7 @@ class _HealthAppState extends State { 0.0, HealthDataType.SLEEP_DEEP, earlier, now); success &= await health.writeMeal( earlier, now, 1000, 50, 25, 50, "Banana", 0.002, MealType.SNACK); + // Store an Audiogram // Uncomment these on iOS - only available on iOS // const frequencies = [125.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0]; @@ -229,17 +236,17 @@ class _HealthAppState extends State { try { 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); } } @@ -249,132 +256,129 @@ class _HealthAppState extends State { try { 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}'), - ); - } + 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}'), ); - }); - } - - 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'); - } + } + 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 _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(); - } + 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 _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, + }; + + // 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; + // } @override Widget build(BuildContext context) { @@ -434,7 +438,7 @@ class _HealthAppState extends State { ], ), Divider(thickness: 3), - Expanded(child: Center(child: _content())) + Expanded(child: Center(child: _content)) ], ), ), diff --git a/packages/health/example/lib/util.dart b/packages/health/example/lib/util.dart index 63d8d7b32..6bfe0c493 100644 --- a/packages/health/example/lib/util.dart +++ b/packages/health/example/lib/util.dart @@ -88,6 +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..922166dab 100644 --- a/packages/health/example/pubspec.yaml +++ b/packages/health/example/pubspec.yaml @@ -4,9 +4,8 @@ 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 diff --git a/packages/health/lib/src/health_factory.dart b/packages/health/lib/src/health_factory.dart index d7c9be1d7..703405cb1 100644 --- a/packages/health/lib/src/health_factory.dart +++ b/packages/health/lib/src/health_factory.dart @@ -34,31 +34,34 @@ class HealthFactory { ? _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 { + /// * 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."); @@ -79,6 +82,7 @@ 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. @@ -91,7 +95,7 @@ class HealthFactory { await _channel.invokeMethod('revokePermissions'); return; } catch (e) { - print(e); + debugPrint('$runtimeType - Exception in revokePermissions(): $e'); } } @@ -122,9 +126,9 @@ class HealthFactory { 'disconnect', {'types': keys, "permissions": mPermissions}); } - /// Requests permissions to access data types in Apple Health or Google Fit. + /// Requests permissions to access health data [types]. /// - /// Returns true if successful, false otherwise + /// Returns true if successful, false otherwise. /// /// Parameters: /// @@ -177,11 +181,8 @@ class HealthFactory { if (_platformType == PlatformType.ANDROID) _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; } @@ -205,7 +206,10 @@ class HealthFactory { /// Calculate the BMI using the last observed height and weight values. Future> _computeAndroidBMI( - DateTime startTime, DateTime endTime, bool includeManualEntry) async { + DateTime startTime, + DateTime endTime, + bool includeManualEntry, + ) async { List heights = await _prepareQuery( startTime, endTime, HealthDataType.HEIGHT, includeManualEntry); @@ -246,7 +250,7 @@ class HealthFactory { return bmiHealthPoints; } - /// Saves health data into Apple Health or Google Fit. + /// Write health data. /// /// Returns true if successful, false otherwise. /// @@ -281,7 +285,7 @@ class HealthFactory { }.contains(type) && _platformType == PlatformType.IOS) 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]!; @@ -315,18 +319,21 @@ 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]. + /// * [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( - HealthDataType type, DateTime startTime, DateTime endTime) async { + HealthDataType type, + DateTime startTime, + DateTime endTime, + ) async { if (startTime.isAfter(endTime)) throw ArgumentError("startTime must be equal or earlier than endTime"); @@ -339,20 +346,25 @@ 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. + /// * [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. Future writeBloodPressure( - int systolic, int diastolic, DateTime startTime, DateTime endTime) async { + int systolic, + int diastolic, + DateTime startTime, + DateTime endTime, + ) async { if (startTime.isAfter(endTime)) throw ArgumentError("startTime must be equal or earlier than endTime"); @@ -366,21 +378,26 @@ class HealthFactory { return success ?? false; } - /// 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. + /// * [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. + /// 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 oxygen saturation + /// is measured only at a specific point in time. Future writeBloodOxygen( - double saturation, DateTime startTime, DateTime endTime, - {double flowRate = 0.0}) async { + double saturation, + DateTime startTime, + DateTime endTime, { + double flowRate = 0.0, + }) async { if (startTime.isAfter(endTime)) throw ArgumentError("startTime must be equal or earlier than endTime"); bool? success; diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 808c0ec15..e2c94817d 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -4,8 +4,8 @@ version: 9.0.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: From e98f6b9b77649a0638707b2a1494c613971b5df7 Mon Sep 17 00:00:00 2001 From: bardram Date: Wed, 27 Mar 2024 15:04:20 +0100 Subject: [PATCH 09/24] improving docs of health factory --- packages/health/example/lib/main.dart | 37 ++++++++-------- packages/health/lib/src/health_factory.dart | 48 +++++++++++++-------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 9dcac4311..96aaae918 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -39,27 +39,27 @@ class _HealthAppState extends State { // Define the types to get. // Use the entire list on e.g. Android. - static final types = dataTypesIOS; + // static final types = dataTypesIOS; // Or specify specific types - // static final types = [ - // HealthDataType.WEIGHT, - // HealthDataType.STEPS, - // HealthDataType.HEIGHT, - // HealthDataType.BLOOD_GLUCOSE, - // HealthDataType.WORKOUT, - // HealthDataType.BLOOD_PRESSURE_DIASTOLIC, - // HealthDataType.BLOOD_PRESSURE_SYSTOLIC, - // // Uncomment this line on iOS - only available on iOS - // // HealthDataType.AUDIOGRAM - // ]; + static final types = [ + HealthDataType.WEIGHT, + HealthDataType.STEPS, + HealthDataType.HEIGHT, + HealthDataType.BLOOD_GLUCOSE, + HealthDataType.WORKOUT, + HealthDataType.BLOOD_PRESSURE_DIASTOLIC, + HealthDataType.BLOOD_PRESSURE_SYSTOLIC, + // Uncomment this line on iOS - only available on iOS + HealthDataType.AUDIOGRAM + ]; // Set up corresponding permissions // READ only - final permissions = types.map((e) => HealthDataAccess.READ).toList(); + // final permissions = types.map((e) => HealthDataAccess.READ).toList(); // Or both READ and WRITE - // final permissions = types.map((e) => HealthDataAccess.READ_WRITE).toList(); + final permissions = types.map((e) => HealthDataAccess.READ_WRITE).toList(); // create a HealthFactory for use in the app HealthFactory health = HealthFactory(useHealthConnectIfAvailable: true); @@ -113,8 +113,8 @@ class _HealthAppState extends State { List healthData = await health.getHealthDataFromTypes(yesterday, now, types); - debugPrint( - 'Total number of data points: ${healthData.length}. Only showing the first 100.'); + 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( @@ -145,7 +145,7 @@ class _HealthAppState extends State { success &= await health.writeHealthData( 1.925, HealthDataType.HEIGHT, earlier, now); success &= - await health.writeHealthData(90, HealthDataType.WEIGHT, earlier, now); + await health.writeHealthData(90, HealthDataType.WEIGHT, now, now); success &= await health.writeHealthData( 90, HealthDataType.HEART_RATE, earlier, now); success &= @@ -179,8 +179,7 @@ class _HealthAppState extends State { success &= await health.writeMeal( earlier, now, 1000, 50, 25, 50, "Banana", 0.002, MealType.SNACK); - // Store an Audiogram - // Uncomment these on iOS - only available on iOS + // 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]; diff --git a/packages/health/lib/src/health_factory.dart b/packages/health/lib/src/health_factory.dart index 703405cb1..432b307b1 100644 --- a/packages/health/lib/src/health_factory.dart +++ b/packages/health/lib/src/health_factory.dart @@ -2,14 +2,23 @@ part of health; /// Main class for the Plugin. /// -/// 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. +/// * 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], and [writeMeal] +/// methods. class HealthFactory { static const MethodChannel _channel = MethodChannel('flutter_health'); String? _deviceId; @@ -264,7 +273,8 @@ class HealthFactory { /// + 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. + /// Values for Sleep and Headache are ignored and will be automatically assigned + /// the default value. Future writeHealthData( double value, HealthDataType type, @@ -547,8 +557,12 @@ class HealthFactory { /// Fetch a list of health data points based on [types]. Future> getHealthAggregateDataFromTypes( - DateTime startDate, DateTime endDate, List types, - {int activitySegmentDuration = 1, bool includeManualEntry = true}) async { + DateTime startDate, + DateTime endDate, + List types, { + int activitySegmentDuration = 1, + bool includeManualEntry = true, + }) async { List dataPoints = []; final result = await _prepareAggregateQuery( @@ -778,7 +792,7 @@ class HealthFactory { return LinkedHashSet.of(points).toList(); } - /// Get the total numbner of steps within a specific time period. + /// 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 @@ -837,13 +851,13 @@ class HealthFactory { /// Returns true if successfully added workout data. /// /// 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. + /// - [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, From 5bb383a8829492f2922755393df927815f26af7a Mon Sep 17 00:00:00 2001 From: bardram Date: Wed, 27 Mar 2024 16:20:13 +0100 Subject: [PATCH 10/24] re-merge of PR #917 + update to README & CHANGELOG --- packages/health/CHANGELOG.md | 237 +- packages/health/README.md | 173 +- .../cachet/plugins/health/HealthPlugin.kt | 3374 +++++++++-------- packages/health/example/lib/main.dart | 6 +- packages/health/lib/src/data_types.dart | 3 + packages/health/pubspec.yaml | 2 +- 6 files changed, 2029 insertions(+), 1766 deletions(-) diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 8f14d1b16..61bc25474 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,284 +1,291 @@ +## 9.1.0 + +* 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) +* Update to API and README docs + ## 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, 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 ## 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 dependancy 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 refelct 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 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). ## 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. +* 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.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 800e245fc..944f4624d 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -14,63 +14,13 @@ 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 | | +See the tables below for supported health and workout data types. ## Setup @@ -87,21 +37,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 @@ -122,9 +87,9 @@ Follow the instructions at @@ -138,7 +103,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): @@ -189,15 +142,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. @@ -213,12 +157,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 ... @@ -242,7 +186,7 @@ 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 `HealthFactory` class 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 @@ -305,7 +249,7 @@ See the example here on pub.dev, 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: -``` +```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})) ``` @@ -329,14 +273,65 @@ List points = ...; points = Health.removeDuplicates(points); ``` -## Workouts +## Data Types -As of 4.0.0 Health supports adding workouts to both iOS and Android. +| **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 | | + +## Workout Types -### Workout Types +As of 4.0.0 Health supports adding workouts to both iOS and Android. -| **Workout Type** | **iOS** | **Android (Google Fit)** | **Android (Health Connect)** | **Comments** | -| -------------------------------- | ------- | ------------------------ | ---------------------------- | ----------------------------------------------------------------- | +| **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/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt index a0dd536d0..50cac775f 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 @@ -48,12 +48,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 @@ -64,18 +63,14 @@ 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 { + 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 var healthConnectRequestPermissionsLauncher: ActivityResultLauncher>? = null private lateinit var healthConnectClient: HealthConnectClient private lateinit var scope: CoroutineScope @@ -87,6 +82,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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" @@ -118,240 +114,254 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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, - ) + 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) { + 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) @@ -360,7 +370,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : checkAvailability() if (healthConnectAvailable) { healthConnectClient = - HealthConnectClient.getOrCreate(flutterPluginBinding.applicationContext) + HealthConnectClient.getOrCreate(flutterPluginBinding.applicationContext) } } @@ -400,9 +410,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } override fun error( - errorCode: String, - errorMessage: String?, - errorDetails: Any?, + errorCode: String, + errorMessage: String?, + errorDetails: Any?, ) { handler?.post { mResult?.error(errorCode, errorMessage, errorDetails) } } @@ -420,17 +430,14 @@ class HealthPlugin(private var channel: MethodChannel? = null) : return false } - private fun onHealthConnectPermissionCallback(permissionGranted: Set) - { - if(permissionGranted.isEmpty()) { - mResult?.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); + } else { + mResult?.success(true) Log.i("FLUTTER_HEALTH", "Access Granted (to Health Connect)!") } - } private fun keyToHealthDataType(type: String): DataType { @@ -503,16 +510,15 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // 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_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 - */ + /** Delete records of the given type in the time range */ private fun delete(call: MethodCall, result: Result) { if (useHealthConnectIfAvailable && healthConnectAvailable) { deleteHCData(call, result) @@ -534,32 +540,36 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val typesBuilder = FitnessOptions.builder() typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - val dataSource = DataDeleteRequest.Builder() - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .addDataType(dataType) - .deleteAllSessions() - .build() + 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) + 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")) + .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) } } - /** - * Save a Blood Pressure measurement with systolic and diastolic values - */ + /** Save a Blood Pressure measurement with systolic and diastolic values */ private fun writeBloodPressure(call: MethodCall, result: Result) { if (useHealthConnectIfAvailable && healthConnectAvailable) { writeBloodPressureHC(call, result) @@ -579,40 +589,43 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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 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 dataSet = DataSet.builder(dataSource).add(dataPoint).build() val fitnessOptions = typesBuilder.build() try { val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) + 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!", - ), - ) + .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) } @@ -633,42 +646,38 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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, - ), + 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, + 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", + "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) } - } } - /** - * Save a Nutrition measurement with calories, carbs, protein, fat, name and mealType - */ + /** 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) @@ -694,16 +703,15 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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 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() - ) + val nutrients = mutableMapOf(Field.NUTRIENT_CALORIES to calories?.toFloat()) if (carbs != null) { nutrients[Field.NUTRIENT_TOTAL_CARBS] = carbs.toFloat() @@ -717,51 +725,46 @@ class HealthPlugin(private var channel: MethodChannel? = null) : nutrients[Field.NUTRIENT_TOTAL_FAT] = fat.toFloat() } - val dataBuilder = DataPoint.builder(dataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .setField(Field.FIELD_NUTRIENTS, nutrients) + 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) } - dataBuilder.setField( - Field.FIELD_MEAL_TYPE, - MapMealTypeToType[mealType] ?: Field.MEAL_TYPE_UNKNOWN + Field.FIELD_MEAL_TYPE, + MapMealTypeToType[mealType] ?: Field.MEAL_TYPE_UNKNOWN ) - val dataPoint = dataBuilder.build() - val dataSet = DataSet.builder(dataSource) - .add(dataPoint) - .build() + val dataSet = DataSet.builder(dataSource).add(dataPoint).build() val fitnessOptions = typesBuilder.build() try { val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) + 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!" + .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) } } - /** - * Save a data type in Google Fit - */ + /** Save a data type in Google Fit */ private fun writeData(call: MethodCall, result: Result) { if (useHealthConnectIfAvailable && healthConnectAvailable) { writeHCData(call, result) @@ -784,34 +787,37 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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 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) + } // 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 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 dataSet = DataSet.builder(dataSource).add(dataPoint).build() if (dataType == DataType.TYPE_SLEEP_SEGMENT) { typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) @@ -819,21 +825,27 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val fitnessOptions = typesBuilder.build() try { val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) + 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")) + .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 + * 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 @@ -856,53 +868,53 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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 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) + } 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 dataSet = DataSet.builder(dataSource).add(dataPoint).build() val fitnessOptions = typesBuilder.build() try { val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) + 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!", - ), - ) + .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 - */ + /** Save a Workout session with options for distance and calories expended */ private fun writeWorkoutData(call: MethodCall, result: Result) { if (useHealthConnectIfAvailable && healthConnectAvailable) { writeWorkoutHCData(call, result) @@ -921,76 +933,79 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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) + val sessionInsertRequestBuilder = + SessionInsertRequest.Builder().setSession(session).addDataSet(activitySegments) if (totalDistance != null) { sessionInsertRequestBuilder.addDataSet(distanceDataSet!!) } @@ -999,43 +1014,47 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } val insertRequest = sessionInsertRequestBuilder.build() - val fitnessOptionsBuilder = FitnessOptions.builder() - .addDataType(DataType.TYPE_ACTIVITY_SEGMENT, FitnessOptions.ACCESS_WRITE) + val fitnessOptionsBuilder = + FitnessOptions.builder() + .addDataType(DataType.TYPE_ACTIVITY_SEGMENT, FitnessOptions.ACCESS_WRITE) if (totalDistance != null) { fitnessOptionsBuilder.addDataType( - DataType.TYPE_DISTANCE_DELTA, - FitnessOptions.ACCESS_WRITE, + DataType.TYPE_DISTANCE_DELTA, + FitnessOptions.ACCESS_WRITE, ) } if (totalEnergyBurned != null) { fitnessOptionsBuilder.addDataType( - DataType.TYPE_CALORIES_EXPENDED, - FitnessOptions.ACCESS_WRITE, + DataType.TYPE_CALORIES_EXPENDED, + FitnessOptions.ACCESS_WRITE, ) } val fitnessOptions = fitnessOptionsBuilder.build() try { val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) + 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")) + 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 - */ + /** Get all datapoints of the DataType within the given time range */ private fun getData(call: MethodCall, result: Result) { if (useHealthConnectIfAvailable && healthConnectAvailable) { getHCData(call, result) @@ -1061,82 +1080,86 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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) + 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) + 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() + 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!", - ), - ) + .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) + 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 + 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!", - ), - ) + .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!", - ), - ) + .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!", + ), + ) } } } @@ -1167,16 +1190,24 @@ class HealthPlugin(private var channel: MethodChannel? = null) : typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) } val fitnessOptions = typesBuilder.build() - val googleSignInAccount = GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) + val googleSignInAccount = + GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) 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!")) + .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!") + ) } private fun getAggregateData(call: MethodCall, result: Result) { @@ -1197,11 +1228,13 @@ class HealthPlugin(private var channel: MethodChannel? = null) : typesBuilder.addDataType(dataType) } val fitnessOptions = typesBuilder.build() - val googleSignInAccount = GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) + val googleSignInAccount = + GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - val readWorkoutsRequest = DataReadRequest.Builder() - .bucketByActivitySegment(activitySegmentDuration, TimeUnit.SECONDS) - .setTimeRange(startTime, endTime, TimeUnit.MILLISECONDS) + val readWorkoutsRequest = + DataReadRequest.Builder() + .bucketByActivitySegment(activitySegmentDuration, TimeUnit.SECONDS) + .setTimeRange(startTime, endTime, TimeUnit.MILLISECONDS) for (type in types) { val dataType = keyToHealthDataType(type) @@ -1209,40 +1242,49 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .readData(readWorkoutsRequest.build()) - .addOnSuccessListener (threadPoolExecutor!!, aggregateDataHandler(includeManualEntry, result)) - .addOnFailureListener(errHandler(result, "There was an error getting the aggregate data!")) + .readData(readWorkoutsRequest.build()) + .addOnSuccessListener( + threadPoolExecutor!!, + aggregateDataHandler(includeManualEntry, result) + ) + .addOnFailureListener( + errHandler(result, "There was an error getting the aggregate data!") + ) } - 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) } + 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) } + } private fun errHandler(result: Result, addMessage: String) = OnFailureListener { exception -> Handler(context!!.mainLooper).run { result.success(null) } @@ -1252,239 +1294,309 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } 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, - ), - ) - } + 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 (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 the sleep session has finer granularity sub-components, extract them: - if (dataSets.isNotEmpty()) { + if (type == SLEEP_AWAKE) { + val dataSets = response.getDataSet(session) for (dataSet in dataSets) { for (dataPoint in dataSet.dataPoints) { - // searching OUT OF BED data - if (dataPoint.getValue(Field.FIELD_SLEEP_SEGMENT_TYPE) - .asInt() != 3 + // 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, + 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, ), - "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, - ), - ) } } + Handler(context!!.mainLooper).run { result.success(healthData) } + } - 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, - ), - ) + 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") } - } - } } - } - 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) - } + 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) } } + 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") + 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); + 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) } - 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) } } - 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() + 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() + 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, + ), + ) } - 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) } } - Handler(context!!.mainLooper).run { result.success(healthData) } - } private fun callToHealthTypes(call: MethodCall): FitnessOptions { val typesBuilder = FitnessOptions.builder() @@ -1506,7 +1618,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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) { @@ -1518,7 +1629,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_WRITE) } - else -> throw IllegalArgumentException("Unknown access type $access") } } @@ -1530,7 +1640,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_WRITE) } - else -> throw IllegalArgumentException("Unknown access type $access") } } @@ -1550,17 +1659,18 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val optionsToRegister = callToHealthTypes(call) - val isGranted = GoogleSignIn.hasPermissions( - GoogleSignIn.getLastSignedInAccount(context!!), - optionsToRegister, - ) + val isGranted = + GoogleSignIn.hasPermissions( + GoogleSignIn.getLastSignedInAccount(context!!), + optionsToRegister, + ) result?.success(isGranted) } /** - * Requests authorization for the HealthDataTypes - * with the the READ or READ_WRITE permission type. + * Requests authorization for the HealthDataTypes with the the READ or READ_WRITE permission + * type. */ private fun requestAuthorization(call: MethodCall, result: Result) { if (context == null) { @@ -1576,16 +1686,17 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val optionsToRegister = callToHealthTypes(call) - // Set to false due to bug described in https://github.com/cph-cachet/flutter-plugins/issues/640#issuecomment-1366830132 + // 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, + activity!!, + GOOGLE_FIT_PERMISSIONS_REQUEST_CODE, + GoogleSignIn.getLastSignedInAccount(context!!), + optionsToRegister, ) } else { // / Permission already granted result?.success(true) @@ -1595,9 +1706,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : /** * 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. + * 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) { @@ -1609,15 +1719,15 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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) - } + .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) { @@ -1634,94 +1744,103 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val stepsDataType = keyToHealthDataType(STEPS) val aggregatedDataType = keyToHealthDataType(AGGREGATE_STEP_COUNT) - val fitnessOptions = FitnessOptions.builder() - .addDataType(stepsDataType) - .addDataType(aggregatedDataType) - .build() + 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 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), - ) - } + val request = + DataReadRequest.Builder() + .aggregate(ds) + .bucketByTime(duration, TimeUnit.MILLISECONDS) + .setTimeRange(start, end, TimeUnit.MILLISECONDS) + .build() - 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) - } + 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) } } - assert(map.size <= 1) { "getTotalStepsInInterval should return only one interval. Found: ${map.size}" } - Handler(context!!.mainLooper).run { - result.success(map.values.firstOrNull()) + 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()) } + } + /// Disconnect Google fit private fun disconnect(call: MethodCall, result: Result) { if (activity == null) { @@ -1733,10 +1852,11 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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 signinOption = + GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestId() + .requestEmail() + .build() val googleSignInClient = GoogleSignIn.getClient(context, signinOption) googleSignInClient.signOut() result.success(true) @@ -1747,9 +1867,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : return workoutTypeMap[type] ?: FitnessActivities.UNKNOWN } - /** - * Handle calls from the MethodChannel - */ + /** Handle calls from the MethodChannel */ override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { "useHealthConnectIfAvailable" -> useHealthConnectIfAvailable(call, result) @@ -1778,15 +1896,15 @@ class HealthPlugin(private var channel: MethodChannel? = null) : binding.addActivityResultListener(this) activity = binding.activity + if (healthConnectAvailable) { + val requestPermissionActivityContract = + PermissionController.createRequestPermissionResultContract() - if ( healthConnectAvailable) { - 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() { @@ -1802,12 +1920,10 @@ class HealthPlugin(private var channel: MethodChannel? = null) : return } activity = null - healthConnectRequestPermissionsLauncher = null; + healthConnectRequestPermissionsLauncher = null } - /** - * HEALTH CONNECT BELOW - */ + /** HEALTH CONNECT BELOW */ var healthConnectAvailable = false var healthConnectStatus = HealthConnectClient.SDK_UNAVAILABLE @@ -1828,7 +1944,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : var permList = mutableListOf() for ((i, typeKey) in types.withIndex()) { - if(!MapToHCType.containsKey(typeKey)) { + if (!MapToHCType.containsKey(typeKey)) { Log.w("FLUTTER_HEALTH::ERROR", "Datatype " + typeKey + " not found in HC") result.success(false) return @@ -1837,41 +1953,49 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val dataType = MapToHCType[typeKey]!! if (access == 0) { permList.add( - HealthPermission.getReadPermission(dataType), + HealthPermission.getReadPermission(dataType), ) } else { permList.addAll( - listOf( - HealthPermission.getReadPermission(dataType), - HealthPermission.getWritePermission(dataType), - ), + 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), - ), + 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), - ), + 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), + healthConnectClient + .permissionController + .getGrantedPermissions() + .containsAll(permList), ) } } @@ -1883,7 +2007,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : var permList = mutableListOf() for ((i, typeKey) in types.withIndex()) { - if(!MapToHCType.containsKey(typeKey)) { + if (!MapToHCType.containsKey(typeKey)) { Log.w("FLUTTER_HEALTH::ERROR", "Datatype " + typeKey + " not found in HC") result.success(false) return @@ -1892,45 +2016,50 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val dataType = MapToHCType[typeKey]!! if (access == 0) { permList.add( - HealthPermission.getReadPermission(dataType), + HealthPermission.getReadPermission(dataType), ) } else { permList.addAll( - listOf( - HealthPermission.getReadPermission(dataType), - HealthPermission.getWritePermission(dataType), - ), + 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), - ), + 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), - ), + listOf( + HealthPermission.getReadPermission(DistanceRecord::class), + HealthPermission.getReadPermission( + TotalCaloriesBurnedRecord::class + ), + HealthPermission.getWritePermission(DistanceRecord::class), + HealthPermission.getWritePermission( + TotalCaloriesBurnedRecord::class + ), + ), ) } } } - if(healthConnectRequestPermissionsLauncher == null) { + if (healthConnectRequestPermissionsLauncher == null) { result.success(false) Log.i("FLUTTER_HEALTH", "Permission launcher not found") - return; + return } - - healthConnectRequestPermissionsLauncher!!.launch(permList.toSet()); + healthConnectRequestPermissionsLauncher!!.launch(permList.toSet()) } fun getHCData(call: MethodCall, result: Result) { @@ -1943,11 +2072,13 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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 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 @@ -1957,11 +2088,12 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // 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 - ) + request = + ReadRecordsRequest( + recordType = classType, + timeRangeFilter = TimeRangeFilter.between(startTime, endTime), + pageToken = pageToken + ) response = healthConnectClient.readRecords(request) pageToken = response.pageToken @@ -1972,40 +2104,49 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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, - ), - ), - ) + 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, - ), - ), - ) + 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), - ), - ) + 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 @@ -2014,42 +2155,46 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // 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, - ), + // 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) { + // 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 { + } else { for (recStage in rec.stages) { if (dataType == MapSleepStageToType[recStage.stage]) { healthConnectData.addAll( - convertRecordStage( - recStage, dataType, - rec.metadata.dataOrigin.packageName - ) + convertRecordStage( + recStage, + dataType, + rec.metadata.dataOrigin.packageName + ) ) } } @@ -2066,18 +2211,21 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } - fun convertRecordStage(stage: SleepSessionRecord.Stage, dataType: String, sourceName: String): - List> { + 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, - ), - ); + 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, + ), + ) } fun getAggregateHCData(call: MethodCall, result: Result) { @@ -2088,36 +2236,38 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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 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) { + if (totalValue is Length) { totalValue = totalValue.inMeters - } - else if(totalValue is Energy) { + } else if (totalValue is Energy) { totalValue = totalValue.inKilocalories } - val packageNames = durationResult.result.dataOrigins.joinToString { - origin -> "${origin.packageName}" - } + 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); + 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) } @@ -2128,392 +2278,493 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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 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, - "stage" to if (record.stages.isNotEmpty()) record.stages[0] else SleepSessionRecord.STAGE_TYPE_UNKNOWN, - ), - ) - 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 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, + "stage" to + if (record.stages.isNotEmpty()) record.stages[0] + else SleepSessionRecord.STAGE_TYPE_UNKNOWN, + ), + ) + 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? + // "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 + // 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() - 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 ") - } + 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, + ) + 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)) @@ -2530,7 +2781,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) val totalEnergyBurned = call.argument("totalEnergyBurned") val totalDistance = call.argument("totalDistance") - if(workoutTypeMapHealthConnect.containsKey(type) == false) { + if (workoutTypeMapHealthConnect.containsKey(type) == false) { result.success(false) Log.w("FLUTTER_HEALTH::ERROR", "[Health Connect] Workout type not supported") return @@ -2541,46 +2792,46 @@ class HealthPlugin(private var channel: MethodChannel? = null) : try { val list = mutableListOf() list.add( - ExerciseSessionRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - exerciseType = workoutType, - title = type, - ), + 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()), - ), + 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()), - ), + TotalCaloriesBurnedRecord( + startTime = startTime, + startZoneOffset = null, + endTime = endTime, + endZoneOffset = null, + energy = Energy.kilocalories(totalEnergyBurned.toDouble()), + ), ) } healthConnectClient.insertRecords( - list, + 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", + "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()) @@ -2598,24 +2849,24 @@ class HealthPlugin(private var channel: MethodChannel? = null) : scope.launch { try { healthConnectClient.insertRecords( - listOf( - BloodPressureRecord( - time = startTime, - systolic = Pressure.millimetersOfMercury(systolic), - diastolic = Pressure.millimetersOfMercury(diastolic), - zoneOffset = null, + 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!", + "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", + "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()) @@ -2628,7 +2879,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val type = call.argument("dataTypeKey")!! val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) - if(!MapToHCType.containsKey(type)) { + if (!MapToHCType.containsKey(type)) { Log.w("FLUTTER_HEALTH::ERROR", "Datatype " + type + " not found in HC") result.success(false) return @@ -2638,8 +2889,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : scope.launch { try { healthConnectClient.deleteRecords( - recordType = classType, - timeRangeFilter = TimeRangeFilter.between(startTime, endTime), + recordType = classType, + timeRangeFilter = TimeRangeFilter.between(startTime, endTime), ) result.success(true) } catch (e: Exception) { @@ -2648,120 +2899,127 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } - 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, - 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 - ) + 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/lib/main.dart b/packages/health/example/lib/main.dart index 96aaae918..bceff8ed3 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -51,15 +51,15 @@ class _HealthAppState extends State { HealthDataType.BLOOD_PRESSURE_DIASTOLIC, HealthDataType.BLOOD_PRESSURE_SYSTOLIC, // Uncomment this line on iOS - only available on iOS - HealthDataType.AUDIOGRAM + // HealthDataType.AUDIOGRAM ]; // Set up corresponding permissions // READ only - // final permissions = types.map((e) => HealthDataAccess.READ).toList(); + final permissions = types.map((e) => HealthDataAccess.READ).toList(); // Or both READ and WRITE - final permissions = types.map((e) => HealthDataAccess.READ_WRITE).toList(); + // final permissions = types.map((e) => HealthDataAccess.READ_WRITE).toList(); // create a HealthFactory for use in the app HealthFactory health = HealthFactory(useHealthConnectIfAvailable: true); diff --git a/packages/health/lib/src/data_types.dart b/packages/health/lib/src/data_types.dart index 156611658..c5fad351d 100644 --- a/packages/health/lib/src/data_types.dart +++ b/packages/health/lib/src/data_types.dart @@ -12,6 +12,7 @@ enum HealthDataType { BODY_FAT_PERCENTAGE, BODY_MASS_INDEX, BODY_TEMPERATURE, + BODY_WATER_MASS, DIETARY_CARBS_CONSUMED, DIETARY_CAFFEINE, DIETARY_ENERGY_CONSUMED, @@ -141,6 +142,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, @@ -177,6 +179,7 @@ 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, diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index e2c94817d..1b45c4b29 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -1,6 +1,6 @@ name: health description: Wrapper for HealthKit on iOS and Google Fit and Health Connect on Android. -version: 9.0.0 +version: 9.1.0 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: From dae29b738b1f555a94291a9564db1bf1b2fe858c Mon Sep 17 00:00:00 2001 From: bardram Date: Fri, 29 Mar 2024 11:22:45 +0100 Subject: [PATCH 11/24] health plugin as a singleton Health() + update to API & docs --- packages/health/CHANGELOG.md | 22 +- packages/health/README.md | 41 +- packages/health/analysis_options.yaml | 19 + packages/health/example/lib/main.dart | 264 ++++----- packages/health/example/pubspec.yaml | 6 +- packages/health/lib/health.dart | 5 + packages/health/lib/health.g.dart | 545 ++++++++++++++++++ packages/health/lib/health.json.dart | 25 + packages/health/lib/src/data_types.dart | 45 +- packages/health/lib/src/functions.dart | 3 +- .../health/lib/src/health_data_point.dart | 258 +++++---- packages/health/lib/src/health_factory.dart | 241 ++++---- .../health/lib/src/health_value_types.dart | 476 +++++++-------- packages/health/lib/src/workout_summary.dart | 83 +-- packages/health/pubspec.yaml | 14 +- 15 files changed, 1320 insertions(+), 727 deletions(-) create mode 100644 packages/health/analysis_options.yaml create mode 100644 packages/health/lib/health.g.dart create mode 100644 packages/health/lib/health.json.dart diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 61bc25474..8c7d06492 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,9 +1,15 @@ -## 9.1.0 +## 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) * 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 @@ -25,7 +31,7 @@ * 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 +* 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 @@ -49,7 +55,7 @@ * 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. +* 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 @@ -115,7 +121,7 @@ ## 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). +* HealthKit CategorySample [PR#485](https://github.com/cph-cachet/flutter-plugins/pull/485). * update of API documentation. ## 3.4.0 @@ -124,7 +130,7 @@ * 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 example app to reflect new features. * update of API documentation. ## 3.3.1 @@ -156,7 +162,7 @@ ## 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 @@ -222,10 +228,6 @@ * Updated the API to take a list of types rather than a single type, when requesting health data. -## 2.0.2 - -* 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 diff --git a/packages/health/README.md b/packages/health/README.md index 944f4624d..9a6ebc06c 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -16,7 +16,6 @@ The plugin supports: - accessing total step counts using the `getTotalStepsInInterval` 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. @@ -186,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, or Google Health Connect. +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, @@ -200,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 @@ -214,23 +213,23 @@ 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; @@ -239,15 +238,19 @@ PlatformType platform; String uuid, deviceId; 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. ### 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: @@ -270,11 +273,13 @@ If you have a list of data points, duplicates can be removed with: ```dart List points = ...; -points = Health.removeDuplicates(points); +points = Health().removeDuplicates(points); ``` ## Data Types +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 | | @@ -328,7 +333,7 @@ points = Health.removeDuplicates(points); ## Workout Types -As of 4.0.0 Health supports adding workouts to both iOS and Android. +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** | | -------------------------------- | ------- | ----------------------- | ---------------------------- | ----------------------------------------------------------------- | 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/example/lib/main.dart b/packages/health/example/lib/main.dart index bceff8ed3..fe9d2b0cd 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -1,14 +1,13 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:health/health.dart'; -import 'package:health_example/util.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:carp_serializable/carp_serializable.dart'; -/// A connivent function to convert a Dart object into a formatted JSON string. -String toJsonString(Object? object) => - const JsonEncoder.withIndent(' ').convert(object); +// /// A connivent function to convert a Dart object into a formatted JSON string. +// String toJsonString(Object? object) => +// const JsonEncoder.withIndent(' ').convert(object); void main() => runApp(HealthApp()); @@ -61,11 +60,15 @@ class _HealthAppState extends State { // Or both READ and WRITE // final permissions = types.map((e) => HealthDataAccess.READ_WRITE).toList(); - // create a HealthFactory for use in the app - HealthFactory health = HealthFactory(useHealthConnectIfAvailable: true); + void initState() { + // configure the health plugin before use. + Health().configure(useHealthConnectIfAvailable: true); + + super.initState(); + } /// 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. @@ -76,7 +79,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. @@ -86,8 +89,8 @@ 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) { debugPrint("Exception in authorize: $error"); } @@ -98,7 +101,7 @@ class _HealthAppState extends State { } /// 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 @@ -111,7 +114,7 @@ class _HealthAppState extends State { try { // fetch health data List healthData = - await health.getHealthDataFromTypes(yesterday, now, types); + await Health().getHealthDataFromTypes(yesterday, now, types); debugPrint('Total number of data points: ${healthData.length}. ' '${healthData.length > 100 ? 'Only showing the first 100.' : ''}'); @@ -124,7 +127,9 @@ class _HealthAppState extends State { } // filter out duplicates - _healthDataList = HealthFactory.removeDuplicates(_healthDataList); + _healthDataList = Health().removeDuplicates(_healthDataList); + + _healthDataList.forEach((data) => debugPrint(toJsonString(data))); // update the UI to display the results setState(() { @@ -133,7 +138,8 @@ 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)); @@ -142,41 +148,41 @@ 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(1.925, HealthDataType.HEIGHT, earlier, now); success &= - await health.writeHealthData(90, HealthDataType.WEIGHT, now, now); - success &= await health.writeHealthData( - 90, HealthDataType.HEART_RATE, earlier, now); + await Health().writeHealthData(90, HealthDataType.WEIGHT, now, now); + success &= await Health() + .writeHealthData(90, HealthDataType.HEART_RATE, earlier, now); success &= - await health.writeHealthData(90, HealthDataType.STEPS, earlier, now); - success &= await health.writeHealthData( + 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(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( + 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_REM, earlier, now); - success &= await health.writeHealthData( - 0.0, HealthDataType.SLEEP_ASLEEP, earlier, now); - success &= await health.writeHealthData( - 0.0, HealthDataType.SLEEP_AWAKE, earlier, now); - success &= await health.writeHealthData( - 0.0, HealthDataType.SLEEP_DEEP, earlier, now); - success &= await health.writeMeal( + success &= await Health().writeBloodPressure(90, 80, earlier, now); + success &= await Health() + .writeHealthData(0.0, HealthDataType.SLEEP_REM, earlier, now); + success &= await Health() + .writeHealthData(0.0, HealthDataType.SLEEP_ASLEEP, earlier, now); + success &= await Health() + .writeHealthData(0.0, HealthDataType.SLEEP_AWAKE, earlier, now); + success &= await Health() + .writeHealthData(0.0, HealthDataType.SLEEP_DEEP, earlier, now); + success &= await Health().writeMeal( earlier, now, 1000, 50, 25, 50, "Banana", 0.002, MealType.SNACK); // Store an Audiogram - only available on iOS @@ -184,7 +190,7 @@ class _HealthAppState extends State { // 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, @@ -202,13 +208,13 @@ 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, earlier, now); } setState(() { @@ -217,7 +223,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) @@ -225,15 +231,15 @@ 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) { debugPrint("Exception in getTotalStepsInInterval: $error"); } @@ -251,14 +257,82 @@ class _HealthAppState extends State { } /// 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) { debugPrint("Exception in revokeAccess: $error"); } } + // UI building below + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Health Example'), + ), + body: Container( + child: Column( + children: [ + Wrap( + spacing: 10, + children: [ + TextButton( + onPressed: authorize, + child: + Text("Auth", style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), + TextButton( + onPressed: fetchData, + child: Text("Fetch Data", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), + TextButton( + onPressed: addData, + child: Text("Add Data", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), + TextButton( + onPressed: deleteData, + child: Text("Delete Data", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), + TextButton( + onPressed: fetchStepData, + child: Text("Fetch Step Data", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), + TextButton( + onPressed: revokeAccess, + child: Text("Revoke Access", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), + ], + ), + Divider(thickness: 3), + Expanded(child: Center(child: _content)) + ], + ), + ), + ), + ); + } + Widget get _contentFetchingData => Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -354,94 +428,4 @@ class _HealthAppState extends State { AppState.DATA_NOT_DELETED => _dataNotDeleted, AppState.STEPS_READY => _stepsFetched, }; - - // 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; - // } - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar( - title: const Text('Health Example'), - ), - body: Container( - child: Column( - children: [ - Wrap( - spacing: 10, - children: [ - TextButton( - onPressed: authorize, - child: - Text("Auth", style: TextStyle(color: Colors.white)), - style: ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: fetchData, - child: Text("Fetch Data", - style: TextStyle(color: Colors.white)), - style: ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: addData, - child: Text("Add Data", - style: TextStyle(color: Colors.white)), - style: ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: deleteData, - child: Text("Delete Data", - style: TextStyle(color: Colors.white)), - style: ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: fetchStepData, - child: Text("Fetch Step Data", - style: TextStyle(color: Colors.white)), - style: ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: revokeAccess, - child: Text("Revoke Access", - style: TextStyle(color: Colors.white)), - style: ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Colors.blue))), - ], - ), - Divider(thickness: 3), - Expanded(child: Center(child: _content)) - ], - ), - ), - ), - ); - } } diff --git a/packages/health/example/pubspec.yaml b/packages/health/example/pubspec.yaml index 922166dab..f23767d27 100644 --- a/packages/health/example/pubspec.yaml +++ b/packages/health/example/pubspec.yaml @@ -6,17 +6,19 @@ version: 4.5.0 environment: 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/lib/health.dart b/packages/health/lib/health.dart index 6fe96a50a..3b31dfb16 100644 --- a/packages/health/lib/health.dart +++ b/packages/health/lib/health.dart @@ -4,6 +4,8 @@ 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'; @@ -14,3 +16,6 @@ part 'src/health_data_point.dart'; part 'src/health_value_types.dart'; part 'src/health_factory.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..0a85cd51e --- /dev/null +++ b/packages/health/lib/health.g.dart @@ -0,0 +1,545 @@ +// 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), + platform: $enumDecode(_$PlatformTypeEnumMap, json['platform']), + deviceId: json['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), + ); + +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(), + 'platform': _$PlatformTypeEnumMap[instance.platform]!, + 'device_id': instance.deviceId, + '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); + 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.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 _$PlatformTypeEnumMap = { + PlatformType.IOS: 'IOS', + PlatformType.ANDROID: 'ANDROID', +}; + +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 int?, + totalEnergyBurnedUnit: $enumDecodeNullable( + _$HealthDataUnitEnumMap, json['total_energy_burned_unit']), + totalDistance: json['total_distance'] as int?, + totalDistanceUnit: $enumDecodeNullable( + _$HealthDataUnitEnumMap, json['total_distance_unit']), + totalSteps: json['total_steps'] as int?, + 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; +} + +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( + json['workout_type'] as String, + json['total_distance'] as num, + json['total_energy_burned'] as num, + 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/data_types.dart b/packages/health/lib/src/data_types.dart index c5fad351d..37ca9b5cd 100644 --- a/packages/health/lib/src/data_types.dart +++ b/packages/health/lib/src/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, @@ -75,8 +75,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, @@ -133,7 +133,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, @@ -168,7 +168,7 @@ const List _dataTypeKeysAndroid = [ ]; /// 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, @@ -326,8 +326,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, @@ -507,24 +508,14 @@ enum ElectrocardiogramClassification { /// 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/functions.dart b/packages/health/lib/src/functions.dart index 24d191fc6..7d1d8a80e 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,6 +11,7 @@ class HealthException implements Exception { HealthException(this.dataType, this.cause); + @override String toString() => "Error requesting health data type '$dataType' - cause: $cause"; } diff --git a/packages/health/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index 1b0e7dac2..8d75f76a3 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -1,33 +1,62 @@ -part of health; +part of '../health.dart'; /// 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; - bool? _isManualEntry; - WorkoutSummary? _workoutSummary; - - HealthDataPoint( - this._value, - this._type, - this._unit, - this._dateFrom, - this._dateTo, - this._platform, - this._deviceId, - this._sourceId, - this._sourceName, - this._isManualEntry, - this._workoutSummary, - ) { + /// 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 software platform of the data point. + PlatformType platform; + + /// The id of the device from which the data point was fetched. + String deviceId; + + /// 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; + + HealthDataPoint({ + required this.value, + required this.type, + required this.unit, + required this.dateFrom, + required this.dateTo, + required this.platform, + required this.deviceId, + required this.sourceId, + required this.sourceName, + this.isManualEntry = false, + this.workoutSummary, + }) { // set the value to minutes rather than the category // returned by the native API if (type == HealthDataType.MINDFULNESS || @@ -43,62 +72,81 @@ 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), + _ => 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 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( + dataPoint["workout_type"] as String? ?? '', + dataPoint["total_distance"] as num? ?? 0, + dataPoint["total_energy_burned"] as num? ?? 0, + dataPoint["total_steps"] as num? ?? 0, + ); } 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['is_manual_entry'], - WorkoutSummary.fromJson(json['workout_summary']), + value: value, + type: dataType, + unit: unit, + dateFrom: from, + dateTo: to, + platform: Health().platformType, + deviceId: Health().deviceId, + sourceId: sourceId, + sourceName: sourceName, + isManualEntry: isManualEntry, + workoutSummary: workoutSummary, ); } - /// 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, - 'is_manual_entry': isManualEntry, - 'workout_summary': workoutSummary?.toJson(), - }; - @override - String toString() => """${this.runtimeType} - + String toString() => """$runtimeType - value: ${value.toString()}, unit: ${unit.name}, dateFrom: $dateFrom, @@ -109,61 +157,21 @@ class HealthDataPoint { sourceId: $sourceId, sourceName: $sourceName isManualEntry: $isManualEntry - workoutSummary: ${workoutSummary?.toString()}"""; - - /// 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; - - /// The user entered state of the data point. - bool? get isManualEntry => _isManualEntry; - - /// The summary of the workout data point. - WorkoutSummary? get workoutSummary => _workoutSummary; + workoutSummary: $workoutSummary"""; @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.isManualEntry == o.isManualEntry; - } + bool operator ==(Object other) => + other is HealthDataPoint && + value == other.value && + unit == other.unit && + dateFrom == other.dateFrom && + dateTo == other.dateTo && + type == other.type && + platform == other.platform && + deviceId == other.deviceId && + sourceId == other.sourceId && + sourceName == other.sourceName && + isManualEntry == other.isManualEntry; @override int get hashCode => Object.hash(value, unit, dateFrom, dateTo, type, platform, diff --git a/packages/health/lib/src/health_factory.dart b/packages/health/lib/src/health_factory.dart index 432b307b1..f12d2f8f8 100644 --- a/packages/health/lib/src/health_factory.dart +++ b/packages/health/lib/src/health_factory.dart @@ -1,4 +1,4 @@ -part of health; +part of '../health.dart'; /// Main class for the Plugin. /// @@ -19,29 +19,56 @@ part of health; /// * Writing different types of specialized health data like the [writeWorkoutData], /// [writeBloodPressure], [writeBloodOxygen], [writeAudiogram], and [writeMeal] /// methods. -class HealthFactory { +class Health { static const MethodChannel _channel = MethodChannel('flutter_health'); + static final _instance = Health._(); + String? _deviceId; final _deviceInfo = DeviceInfoPlugin(); late bool _useHealthConnectIfAvailable; - static PlatformType _platformType = + Health._() { + _registerFromJsonFunctions(); + } + + /// Get the singleton [Health] instance. + factory Health() => _instance; + + /// The type of platform of this device. + PlatformType get platformType => Platform.isAndroid ? PlatformType.ANDROID : PlatformType.IOS; - /// The plugin was created to use Health Connect (if true) or Google Fit (if false). - bool get useHealthConnectIfAvailable => _useHealthConnectIfAvailable; + /// 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 ??= platformType == PlatformType.ANDROID + ? (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'); + } } + /// Using Health Connect (if true) or Google Fit (if 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); + platformType == PlatformType.ANDROID + ? dataTypeKeysAndroid.contains(dataType) + : dataTypeKeysIOS.contains(dataType); /// Determines if the health data [types] have been granted with the specified /// access rights [permissions]. @@ -71,9 +98,10 @@ class HealthFactory { List types, { List? permissions, }) async { - if (permissions != null && permissions.length != types.length) + 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 @@ -82,7 +110,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 (platformType == PlatformType.ANDROID) _handleBMI(mTypes, mPermissions); return await _channel.invokeMethod('hasPermissions', { "types": mTypes.map((type) => type.name).toList(), @@ -97,7 +125,7 @@ class HealthFactory { /// Not implemented on iOS as there is no way to programmatically remove access. Future revokePermissions() async { try { - if (_platformType == PlatformType.IOS) { + if (platformType == PlatformType.IOS) { throw UnsupportedError( 'Revoke permissions is not supported on iOS. Please revoke permissions manually in the settings.'); } @@ -127,7 +155,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 (platformType == PlatformType.ANDROID) _handleBMI(mTypes, mPermissions); List keys = mTypes.map((dataType) => dataType.name).toList(); @@ -187,7 +215,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 (platformType == PlatformType.ANDROID) _handleBMI(mTypes, mPermissions); List keys = mTypes.map((e) => e.name).toList(); final bool? isAuthorized = await _channel.invokeMethod( @@ -196,10 +224,10 @@ class HealthFactory { } /// 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 && platformType == PlatformType.ANDROID) { if (!mTypes.contains(HealthDataType.WEIGHT)) { mTypes.add(HealthDataType.WEIGHT); mPermissions.add(mPermissions[index]); @@ -233,7 +261,7 @@ class HealthFactory { (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++) { @@ -241,17 +269,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!, - '', - '', - !includeManualEntry, - null, + value: NumericHealthValue(numericValue: bmiValue), + type: dataType, + unit: unit, + dateFrom: weights[i].dateFrom, + dateTo: weights[i].dateTo, + platform: platformType, + deviceId: _deviceId!, + sourceId: '', + sourceName: '', + isManualEntry: !includeManualEntry, ); bmiHealthPoints.add(x); @@ -282,23 +309,26 @@ class HealthFactory { DateTime endTime, { HealthDataUnit? unit, }) async { - if (type == HealthDataType.WORKOUT) + if (type == HealthDataType.WORKOUT) { throw ArgumentError( "Adding workouts should be done using the writeWorkoutData method."); - if (startTime.isAfter(endTime)) + } + 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) + platformType == PlatformType.IOS) { throw ArgumentError( "$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 @@ -344,8 +374,9 @@ class HealthFactory { DateTime startTime, DateTime endTime, ) async { - if (startTime.isAfter(endTime)) + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } Map args = { 'dataTypeKey': type.name, @@ -375,8 +406,9 @@ class HealthFactory { DateTime startTime, DateTime endTime, ) async { - if (startTime.isAfter(endTime)) + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } Map args = { 'systolic': systolic, @@ -384,8 +416,7 @@ 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. @@ -408,14 +439,15 @@ class HealthFactory { DateTime endTime, { double flowRate = 0.0, }) async { - if (startTime.isAfter(endTime)) + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } bool? success; - if (_platformType == PlatformType.IOS) { + if (platformType == PlatformType.IOS) { success = await writeHealthData( saturation, HealthDataType.BLOOD_OXYGEN, startTime, endTime); - } else if (_platformType == PlatformType.ANDROID) { + } else if (platformType == PlatformType.ANDROID) { Map args = { 'value': saturation, 'flowRate': flowRate, @@ -452,8 +484,9 @@ class HealthFactory { String? name, double? caffeine, MealType mealType) async { - if (startTime.isAfter(endTime)) + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); + } Map args = { 'startTime': startTime.millisecondsSinceEpoch, @@ -493,17 +526,22 @@ class HealthFactory { {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)) + } + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); - if (_platformType == PlatformType.ANDROID) + } + if (platformType == PlatformType.ANDROID) { throw UnsupportedError("writeAudiogram is not supported on Android"); + } + Map args = { 'frequencies': frequencies, 'leftEarSensitivities': leftEarSensitivities, @@ -513,8 +551,7 @@ class HealthFactory { 'endTime': endTime.millisecondsSinceEpoch, 'metadata': metadata, }; - bool? success = await _channel.invokeMethod('writeAudiogram', args); - return success ?? false; + return await _channel.invokeMethod('writeAudiogram', args) == true; } /// Fetch a list of health data points based on [types]. @@ -579,19 +616,19 @@ class HealthFactory { HealthDataType dataType, bool includeManualEntry) async { // Ask for device ID only once - _deviceId ??= _platformType == PlatformType.ANDROID + _deviceId ??= platformType == PlatformType.ANDROID ? (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) { + platformType == PlatformType.ANDROID) { return _computeAndroidBMI(startTime, endTime, includeManualEntry); } return await _dataQuery(startTime, endTime, dataType, includeManualEntry); @@ -605,14 +642,14 @@ class HealthFactory { int interval, bool includeManualEntry) async { // Ask for device ID only once - _deviceId ??= _platformType == PlatformType.ANDROID + _deviceId ??= platformType == PlatformType.ANDROID ? (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'); } return await _dataIntervalQuery( @@ -627,14 +664,14 @@ class HealthFactory { int activitySegmentDuration, bool includeManualEntry) async { // Ask for device ID only once - _deviceId ??= _platformType == PlatformType.ANDROID + _deviceId ??= platformType == PlatformType.ANDROID ? (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'); + throw HealthException(type, 'Not available on platform $platformType'); } } @@ -647,26 +684,25 @@ class HealthFactory { HealthDataType dataType, bool includeManualEntry) async { final args = { 'dataTypeKey': dataType.name, - 'dataUnitKey': _dataTypeToUnit[dataType]!.name, + 'dataUnitKey': dataTypeToUnit[dataType]!.name, 'startTime': startTime.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 []; } @@ -681,7 +717,7 @@ class HealthFactory { bool includeManualEntry) async { final args = { 'dataTypeKey': dataType.name, - 'dataUnitKey': _dataTypeToUnit[dataType]!.name, + 'dataUnitKey': dataTypeToUnit[dataType]!.name, 'startTime': startDate.millisecondsSinceEpoch, 'endTime': endDate.millisecondsSinceEpoch, 'interval': interval, @@ -691,12 +727,11 @@ class HealthFactory { final fetchedDataPoints = await _channel.invokeMethod('getIntervalData', args); if (fetchedDataPoints != null) { - final mesg = { + final msg = { "dataType": dataType, "dataPoints": fetchedDataPoints, - "deviceId": _deviceId!, }; - return _parse(mesg); + return _parse(msg); } return []; } @@ -718,77 +753,30 @@ class HealthFactory { final fetchedDataPoints = await _channel.invokeMethod('getAggregateData', args); + if (fetchedDataPoints != null) { - final mesg = { + final msg = { "dataType": HealthDataType.WORKOUT, "dataPoints": fetchedDataPoints, - "deviceId": _deviceId!, }; - return _parse(mesg); + return _parse(msg); } return []; } - 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 && - e["totalEnergyBurned"] != null) { - value = WorkoutHealthValue.fromJson(e); - } else if (dataType == HealthDataType.ELECTROCARDIOGRAM) { - value = ElectrocardiogramHealthValue.fromJson(e); - } else if (dataType == HealthDataType.NUTRITION) { - value = NutritionHealthValue.fromJson(e); - } else { - value = NumericHealthValue(e['value'] ?? 0); - } - 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 bool? isManualEntry = e["is_manual_entry"]; - - // Set WorkoutSummary - WorkoutSummary? workoutSummary; - if (e["workout_type"] != null || - e["total_distance"] != null || - e["total_energy_burned"] != null || - e["total_steps"] != null) { - workoutSummary = WorkoutSummary( - e["workout_type"] ?? '', - e["total_distance"] ?? 0, - e["total_energy_burned"] ?? 0, - e["total_steps"] ?? 0, - ); - } - return HealthDataPoint( - value, - dataType, - unit, - from, - to, - _platformType, - device, - sourceId, - sourceName, - isManualEntry, - workoutSummary, - ); - }).toList(); + List _parse(Map message) { + final dataType = message["dataType"] as HealthDataType; + final dataPoints = message["dataPoints"] as List; - return list; + return dataPoints + .map((dataPoint) => + HealthDataPoint.fromHealthDataPoint(dataType, dataPoint)) + .toList(); } /// Given an array of [HealthDataPoint]s, this method will return the array /// without any duplicates. - static List removeDuplicates(List points) { + List removeDuplicates(List points) { return LinkedHashSet.of(points).toList(); } @@ -868,10 +856,10 @@ class HealthFactory { HealthDataUnit totalDistanceUnit = HealthDataUnit.METER, }) async { // Check that value is on the current Platform - if (_platformType == PlatformType.IOS && !_isOnIOS(activityType)) { + if (platformType == PlatformType.IOS && !_isOnIOS(activityType)) { throw HealthException(activityType, "Workout activity type $activityType is not supported on iOS"); - } else if (_platformType == PlatformType.ANDROID && + } else if (platformType == PlatformType.ANDROID && !_isOnAndroid(activityType)) { throw HealthException(activityType, "Workout activity type $activityType is not supported on Android"); @@ -885,8 +873,7 @@ class HealthFactory { 'totalDistance': totalDistance, 'totalDistanceUnit': totalDistanceUnit.name, }; - 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 0c2f18e1a..ad5fdecbb 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,109 +111,98 @@ 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; - int? _totalSteps; - HealthDataUnit? _totalStepsUnit; - - WorkoutHealthValue( - this._workoutActivityType, - this._totalEnergyBurned, - this._totalEnergyBurnedUnit, - this._totalDistance, - this._totalDistanceUnit, - this._totalSteps, - this._totalStepsUnit); - /// 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; + HealthDataUnit? totalDistanceUnit; /// The total steps covered during the workout. /// Might not be available for all workouts. - int? get totalSteps => _totalSteps; + int? totalSteps; /// The unit of the total steps covered during the workout. /// Might not be available for all workouts. - HealthDataUnit? get totalStepsUnit => _totalStepsUnit; - - 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, - json['totalSteps'] != null ? (json['totalSteps'] as num).toInt() : null, - json['totalStepsUnit'] != null - ? HealthDataUnit.values - .firstWhere((element) => element.name == json['totalStepsUnit']) - : null); - } - - @override - Map toJson() => { - 'workoutActivityType': _workoutActivityType.name, - 'totalEnergyBurned': _totalEnergyBurned, - 'totalEnergyBurnedUnit': _totalEnergyBurnedUnit?.name, - 'totalDistance': _totalDistance, - 'totalDistanceUnit': _totalDistanceUnit?.name, - 'totalSteps': _totalSteps, - 'totalStepsUnit': _totalStepsUnit?.name, - }; - - @override - String toString() { - return """workoutActivityType: ${workoutActivityType.name}, + 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 + Function get fromJsonFunction => _$WorkoutHealthValueFromJson; + factory WorkoutHealthValue.fromJson(Map json) => + FromJsonFactory().fromJson(json) as WorkoutHealthValue; + @override + Map toJson() => _$WorkoutHealthValueToJson(this); + + @override + String toString() => + """$runtimeType - workoutActivityType: ${workoutActivityType.name}, totalEnergyBurned: $totalEnergyBurned, totalEnergyBurnedUnit: ${totalEnergyBurnedUnit?.name}, totalDistance: $totalDistance, 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 && - this.totalSteps == o.totalSteps && - this.totalStepsUnit == o.totalStepsUnit; - } + 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( @@ -223,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; @@ -234,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( @@ -277,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; @@ -288,124 +289,131 @@ 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 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 -/// * [caffeine] - the amount of caffeine 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; - double? _caffeine; - String _mealType; - - NutritionHealthValue(this._protein, this._calories, this._fat, this._name, - this._carbs, this._caffeine, 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; + double? carbs; /// The amount of caffeine in grams. - double? get caffeine => _caffeine; + double? caffeine; + + NutritionHealthValue({ + this.mealType, + this.protein, + this.calories, + this.fat, + this.name, + this.carbs, + this.caffeine, + }); - /// 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['caffeine'] != null ? (json['caffeine'] as num).toDouble() : null, - json['mealType'] as String, - ); - } - - @override - Map toJson() => { - 'protein': _protein, - 'calories': _calories, - 'fat': _fat, - 'name': _name, - 'carbs': _carbs, - 'caffeine': _caffeine, - 'mealType': _mealType, - }; - - @override - String toString() { - return """protein: ${protein.toString()}, + @override + 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() => """$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.caffeine == this.caffeine && - 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, caffeine); } - -/// An abstract class for health values. -abstract class HealthValue { - Map toJson(); -} diff --git a/packages/health/lib/src/workout_summary.dart b/packages/health/lib/src/workout_summary.dart index 5d04521b2..81dab385c 100644 --- a/packages/health/lib/src/workout_summary.dart +++ b/packages/health/lib/src/workout_summary.dart @@ -1,54 +1,57 @@ -part of health; +part of '../health.dart'; /// A [WorkoutSummary] object store vary metrics of a workout. -/// * totalDistance - The total distance that was traveled during a workout. -/// * totalEnergyBurned - The amount of energy that was burned during a workout. -/// * totalSteps - The count of steps was burned during a workout. +/// * totalDistance - The total distance that was traveled during a workout. +/// * totalEnergyBurned - The amount of energy that was burned during a workout. +/// * totalSteps - The count of steps was burned during a workout. +@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false) class WorkoutSummary { - String _workoutType; - num _totalDistance; - num _totalEnergyBurned; - num _totalSteps; + /// 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( - this._workoutType, - this._totalDistance, - this._totalEnergyBurned, - this._totalSteps, + this.workoutType, + this.totalDistance, + this.totalEnergyBurned, + this.totalSteps, ); - /// Converts a json object to the [WorkoutSummary] - factory WorkoutSummary.fromJson(json) => WorkoutSummary( - json['workoutType'], - json['totalDistance'], - json['totalEnergyBurned'], - json['totalSteps'], - ); - - /// Converts the [WorkoutSummary] to a json object - Map toJson() => { - 'workoutType': workoutType, - 'totalDistance': totalDistance, - 'totalEnergyBurned': totalEnergyBurned, - 'totalSteps': totalSteps - }; + /// Create a [HealthDataPoint] from json. + factory WorkoutSummary.fromJson(Map json) => + _$WorkoutSummaryFromJson(json); + + /// Convert this [HealthDataPoint] to json. + Map toJson() => _$WorkoutSummaryToJson(this); + + // /// Converts a json object to the [WorkoutSummary] + // factory WorkoutSummary.fromJson(json) => WorkoutSummary( + // json['workoutType'], + // json['totalDistance'], + // json['totalEnergyBurned'], + // json['totalSteps'], + // ); + + // /// Converts the [WorkoutSummary] to a json object + // Map toJson() => { + // 'workoutType': workoutType, + // 'totalDistance': totalDistance, + // 'totalEnergyBurned': totalEnergyBurned, + // 'totalSteps': totalSteps + // }; @override - String toString() => '${this.runtimeType} - ' + String toString() => '$runtimeType - ' 'workoutType: $workoutType' 'totalDistance: $totalDistance, ' 'totalEnergyBurned: $totalEnergyBurned, ' 'totalSteps: $totalSteps'; - - /// Workout type. - String get workoutType => _workoutType; - - /// The total distance value of the workout. - num get totalDistance => _totalDistance; - - /// The total energy burned value of the workout. - num get totalEnergyBurned => _totalEnergyBurned; - - /// The total steps value of the workout. - num get totalSteps => _totalSteps; } diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 1b45c4b29..578b4b8f2 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -1,6 +1,6 @@ name: health description: Wrapper for HealthKit on iOS and Google Fit and Health Connect on Android. -version: 9.1.0 +version: 10.0.0 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: @@ -10,14 +10,22 @@ environment: 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: From fd405e207203c5fc5c3fc3617438e7c59daaf962 Mon Sep 17 00:00:00 2001 From: bardram Date: Fri, 29 Mar 2024 21:04:40 +0100 Subject: [PATCH 12/24] health published as 10.0.0 --- packages/health/CHANGELOG.md | 1 + .../cachet/plugins/health/HealthPlugin.kt | 6701 ++++++++++------- packages/health/example/lib/main.dart | 54 +- packages/health/lib/health.dart | 2 +- ...health_factory.dart => health_plugin.dart} | 88 +- 5 files changed, 3923 insertions(+), 2923 deletions(-) rename packages/health/lib/src/{health_factory.dart => health_plugin.dart} (95%) diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 8c7d06492..56b44a6a4 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -6,6 +6,7 @@ * 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) +* 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. 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 50cac775f..63323ba88 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 @@ -63,2963 +63,3970 @@ 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 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, - ) - - // 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 - } - - 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") - } - } - - 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()) - } - } + 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, + ) - /** Delete records of the given type in the time range */ - private fun delete(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - deleteHCData(call, result) - return + // 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 + ) + } } - 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 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) - } + + 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)!") + } } - } - /** 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 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") + } } - if (context == null) { - result.success(false) - 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") + } } - 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")!! + 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 dataType = DataType.TYPE_NUTRITION + /** 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 typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + val type = call.argument("dataTypeKey")!! + val startTime = call.argument("startTime")!! + val endTime = call.argument("endTime")!! - val dataSource = - DataSource.Builder() - .setDataType(dataType) - .setType(DataSource.TYPE_RAW) - .setDevice(Device.getLocalDevice(context!!.applicationContext)) - .setAppPackageName(context!!.applicationContext) - .build() + // Look up data type and unit for the type key + val dataType = keyToHealthDataType(type) + val field = getField(type) - val nutrients = mutableMapOf(Field.NUTRIENT_CALORIES to calories?.toFloat()) + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - if (carbs != null) { - nutrients[Field.NUTRIENT_TOTAL_CARBS] = carbs.toFloat() - } + val dataSource = + DataDeleteRequest.Builder() + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .addDataType(dataType) + .deleteAllSessions() + .build() - if (protein != null) { - nutrients[Field.NUTRIENT_PROTEIN] = protein.toFloat() - } + val fitnessOptions = typesBuilder.build() - if (fat != null) { - nutrients[Field.NUTRIENT_TOTAL_FAT] = fat.toFloat() + 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) + } } - 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) - } + /** 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 + } - dataBuilder.setField( - Field.FIELD_MEAL_TYPE, - MapMealTypeToType[mealType] ?: Field.MEAL_TYPE_UNKNOWN - ) - - val dataPoint = dataBuilder.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 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() - /** 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 dataPoint = builder + val dataSet = DataSet.builder(dataSource).add(dataPoint).build() - 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) - } - - // 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 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) } - - val dataSet = DataSet.builder(dataSource).add(dataPoint).build() - - 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 } - if (context == null) { - result.success(false) - return + 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 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) - } - - 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 Nutrition measurement with calories, carbs, protein, fat, name and mealType */ + private fun writeMeal(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + writeMealHC(call, result) + return + } - /** 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 (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() - } + 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() - // 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) - } - } + val nutrients = mutableMapOf(Field.NUTRIENT_CALORIES to calories?.toFloat()) - /** 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 (carbs != null) { + nutrients[Field.NUTRIENT_TOTAL_CARBS] = carbs.toFloat() + } - if (context == null) { - result.success(null) - return - } + if (protein != null) { + nutrients[Field.NUTRIENT_PROTEIN] = protein.toFloat() + } - 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!", - ), - ) - } - 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!", - ), - ) - } - } - } + if (fat != null) { + nutrients[Field.NUTRIENT_TOTAL_FAT] = fat.toFloat() + } - private fun getIntervalData(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - getAggregateHCData(call, result) - return - } + val dataBuilder = + DataPoint.builder(dataSource) + .setTimeInterval( + startTime, + endTime, + TimeUnit.MILLISECONDS + ) + .setField(Field.FIELD_NUTRIENTS, nutrients) - if (context == null) { - result.success(null) - return - } + if (name != null) { + dataBuilder.setField(Field.FIELD_FOOD_ITEM, name as String) + } - 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) - } - val fitnessOptions = typesBuilder.build() - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - - 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!") + dataBuilder.setField( + Field.FIELD_MEAL_TYPE, + MapMealTypeToType[mealType] ?: Field.MEAL_TYPE_UNKNOWN ) - } - private fun getAggregateData(call: MethodCall, result: Result) { - if (context == null) { - result.success(null) - return - } + val dataPoint = dataBuilder.build() - 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")!! + val dataSet = DataSet.builder(dataSource).add(dataPoint).build() - 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) + 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) + } } - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .readData(readWorkoutsRequest.build()) - .addOnSuccessListener( - threadPoolExecutor!!, - aggregateDataHandler(includeManualEntry, result) - ) - .addOnFailureListener( - errHandler(result, "There was an error getting the aggregate data!") - ) - } - - 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) } - } - - 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( + /** 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 - ), - "date_to" to - dataPoint.getEndTime( + ) + } else { + DataPoint.builder(dataSource) + .setTimeInterval( + startTime, + endTime, 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, - ), - ) + + // 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() + + if (dataType == DataType.TYPE_SLEEP_SEGMENT) { + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) } - 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) - } + 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) } - } } - 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") + /** + * 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 } - } - } - return typesBuilder.build() - } - private fun hasPermissions(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectAvailable) { - hasPermissionsHC(call, result) - return - } - if (context == null) { - result.success(false) - return - } + if (context == null) { + result.success(false) + return + } - val optionsToRegister = callToHealthTypes(call) + 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 isGranted = - GoogleSignIn.hasPermissions( - GoogleSignIn.getLastSignedInAccount(context!!), - optionsToRegister, - ) + builder.setField(HealthFields.FIELD_SUPPLEMENTAL_OXYGEN_FLOW_RATE, flowRate) + builder.setField(HealthFields.FIELD_OXYGEN_SATURATION, saturation) - result?.success(isGranted) - } - - /** - * 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 + val dataPoint = builder.build() + val dataSet = DataSet.builder(dataSource).add(dataPoint).build() - if (useHealthConnectIfAvailable && healthConnectAvailable) { - requestAuthorizationHC(call, result) - return + 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) + } } - 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) - } - } - - /** - * 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) + /** 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 } - .addOnFailureListener { e -> - Log.w("Health", "There was an error disabling Google Fit", e) - result.success(false) + if (context == null) { + result.success(false) + return } - } - private fun getTotalStepsInInterval(call: MethodCall, result: Result) { - val start = call.argument("startTime")!! - val end = call.argument("endTime")!! + 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() + } - if (useHealthConnectIfAvailable && healthConnectAvailable) { - getStepsHealthConnect(start, end, 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 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), - ) - } + 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() - 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) + 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) { - 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()) } - } - - /// 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) - } - } - - 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) - "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() + result.success(false) + } } - } - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - if (channel == null) { - return - } - binding.addActivityResultListener(this) - activity = binding.activity + /** 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 (healthConnectAvailable) { - val requestPermissionActivityContract = - PermissionController.createRequestPermissionResultContract() + if (context == null) { + result.success(null) + return + } - healthConnectRequestPermissionsLauncher = - (activity as ComponentActivity).registerForActivityResult( - requestPermissionActivityContract - ) { granted -> onHealthConnectPermissionCallback(granted) } + 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!", + ), + ) + } + 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!", + ), + ) + } + } } - } - override fun onDetachedFromActivityForConfigChanges() { - onDetachedFromActivity() - } + private fun getIntervalData(call: MethodCall, result: Result) { + if (useHealthConnectIfAvailable && healthConnectAvailable) { + getAggregateHCData(call, result) + return + } - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - onAttachedToActivity(binding) - } + if (context == null) { + result.success(null) + return + } - override fun onDetachedFromActivity() { - if (channel == null) { - return + 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) + } + val fitnessOptions = typesBuilder.build() + val googleSignInAccount = + GoogleSignIn.getAccountForExtension( + context!!.applicationContext, + fitnessOptions + ) + + 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!" + ) + ) } - 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), - ), - ) - } - // 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 - ), - ), - ) - } - } + + private fun getAggregateData(call: MethodCall, result: Result) { + if (context == null) { + result.success(null) + return + } + + 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")!! + + 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) + } + + Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) + .readData(readWorkoutsRequest.build()) + .addOnSuccessListener( + threadPoolExecutor!!, + aggregateDataHandler(includeManualEntry, result) + ) + .addOnFailureListener( + errHandler( + result, + "There was an error getting the aggregate data!" + ) + ) } - scope.launch { - result.success( - healthConnectClient - .permissionController - .getGrantedPermissions() - .containsAll(permList), - ) + + 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) } } - } - - 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 - ), - ), - ) - } - } + + 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() } - if (healthConnectRequestPermissionsLauncher == null) { - result.success(false) - Log.i("FLUTTER_HEALTH", "Permission launcher not found") - 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) } - 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), + /** + * 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) + } + } - 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, - ), - ), + /** + * 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!!)!! ) - var totalDistance = 0.0 - for (distanceRec in distanceRequest.records) { - totalDistance += distanceRec.distance.inMeters + .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 + } + + 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) + } } - val energyBurnedRequest = - healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = TotalCaloriesBurnedRecord::class, - timeRangeFilter = - TimeRangeFilter.between( - record.startTime, - record.endTime, - ), - ), + 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", ) - var totalEnergyBurned = 0.0 - for (energyBurnedRec in energyBurnedRequest.records) { - totalEnergyBurned += energyBurnedRec.energy.inKilocalories + map[startTime] = count.asInt() + } else { + val startDay = Date(start) + val endDay = Date(end) + Log.i("FLUTTER_HEALTH::ERROR", "no steps for $startDay - $endDay") } + } - val stepRequest = - healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = StepsRecord::class, - timeRangeFilter = - TimeRangeFilter.between( - record.startTime, - record.endTime + assert(map.size <= 1) { + "getTotalStepsInInterval should return only one interval. Found: ${map.size}" + } + Handler(context!!.mainLooper).run { result.success(map.values.firstOrNull()) } + } + + /// 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) + } + } + + 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) + "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() + } + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + if (channel == null) { + return + } + binding.addActivityResultListener(this) + activity = binding.activity + + if (healthConnectAvailable) { + val requestPermissionActivityContract = + PermissionController.createRequestPermissionResultContract() + + healthConnectRequestPermissionsLauncher = + (activity as ComponentActivity).registerForActivityResult( + requestPermissionActivityContract + ) { granted -> onHealthConnectPermissionCallback(granted) } + } + } + + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onDetachedFromActivity() { + if (channel == null) { + return + } + 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 + ), + ), + ) + } + // 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), + ) + } + } + + 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" ) - var totalSteps = 0.0 - for (stepRec in stepRequest.records) { - totalSteps += stepRec.count + 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 + ), + ), + ) + } + } + } + if (healthConnectRequestPermissionsLauncher == null) { + result.success(false) + Log.i("FLUTTER_HEALTH", "Permission launcher not found") + return + } - // 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 + 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) + ) + } } - } } - } - } else { - for (rec in records) { - healthConnectData.addAll(convertRecord(rec, dataType)) - } + Handler(context!!.mainLooper).run { result.success(healthConnectData) } } - } - 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, - ), - ) - } - - 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) } } - } - - // 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, - "stage" to - if (record.stages.isNotEmpty()) record.stages[0] - else SleepSessionRecord.STAGE_TYPE_UNKNOWN, - ), - ) - 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? + + 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 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, - ) - 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) - } + + 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]!! + + 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() + ), + ), + ) + } + 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, - 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 - ) + + 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/lib/main.dart b/packages/health/example/lib/main.dart index fe9d2b0cd..4da7b0c7c 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:health/health.dart'; +import 'package:health_example/util.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:carp_serializable/carp_serializable.dart'; @@ -38,20 +39,21 @@ class _HealthAppState extends State { // Define the types to get. // Use the entire list on e.g. Android. - // static final types = dataTypesIOS; - - // Or specify specific types - static final types = [ - HealthDataType.WEIGHT, - HealthDataType.STEPS, - HealthDataType.HEIGHT, - HealthDataType.BLOOD_GLUCOSE, - HealthDataType.WORKOUT, - HealthDataType.BLOOD_PRESSURE_DIASTOLIC, - HealthDataType.BLOOD_PRESSURE_SYSTOLIC, - // Uncomment this line on iOS - only available on iOS - // HealthDataType.AUDIOGRAM - ]; + static final types = dataTypesIOS; + // static final types = dataTypesAndroid; + + // // Or specify specific types + // static final types = [ + // HealthDataType.WEIGHT, + // HealthDataType.STEPS, + // HealthDataType.HEIGHT, + // HealthDataType.BLOOD_GLUCOSE, + // HealthDataType.WORKOUT, + // HealthDataType.BLOOD_PRESSURE_DIASTOLIC, + // HealthDataType.BLOOD_PRESSURE_SYSTOLIC, + // // Uncomment this line on iOS - only available on iOS + // // HealthDataType.AUDIOGRAM + // ]; // Set up corresponding permissions // READ only @@ -138,7 +140,8 @@ class _HealthAppState extends State { } /// Add some random health data. - /// Note that you should ensure that you have permissions to add the following data types. + /// 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)); @@ -148,6 +151,8 @@ 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; + + // misc. health data examples using the writeHealthData() method success &= await Health() .writeHealthData(1.925, HealthDataType.HEIGHT, earlier, now); success &= @@ -167,13 +172,8 @@ class _HealthAppState extends State { .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); + + // different types of sleep success &= await Health() .writeHealthData(0.0, HealthDataType.SLEEP_REM, earlier, now); success &= await Health() @@ -182,6 +182,15 @@ class _HealthAppState extends State { .writeHealthData(0.0, HealthDataType.SLEEP_AWAKE, earlier, now); success &= await Health() .writeHealthData(0.0, HealthDataType.SLEEP_DEEP, earlier, now); + + // specialized write methods + 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().writeMeal( earlier, now, 1000, 50, 25, 50, "Banana", 0.002, MealType.SNACK); @@ -189,7 +198,6 @@ class _HealthAppState extends State { // 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( // frequencies, // leftEarSensitivities, diff --git a/packages/health/lib/health.dart b/packages/health/lib/health.dart index 3b31dfb16..d33ffd440 100644 --- a/packages/health/lib/health.dart +++ b/packages/health/lib/health.dart @@ -14,7 +14,7 @@ part 'src/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'; diff --git a/packages/health/lib/src/health_factory.dart b/packages/health/lib/src/health_plugin.dart similarity index 95% rename from packages/health/lib/src/health_factory.dart rename to packages/health/lib/src/health_plugin.dart index f12d2f8f8..66f7caa76 100644 --- a/packages/health/lib/src/health_factory.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -1,6 +1,8 @@ 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. /// /// Overall, the plugin supports: /// @@ -59,7 +61,7 @@ class Health { } } - /// Using Health Connect (if true) or Google Fit (if false)? + /// Is this plugin using Health Connect (true) or Google Fit (false)? /// /// This is set in the [configure] method. bool get useHealthConnectIfAvailable => _useHealthConnectIfAvailable; @@ -136,9 +138,9 @@ class Health { } } - /// Disconnect Google fit. + /// Disconnect from Google fit. /// - /// Not supported on iOS and method does nothing. + /// Not supported on iOS and Google Health Connect, and the method does nothing. Future disconnect( List types, { List? permissions, @@ -503,20 +505,20 @@ class Health { 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 + /// * [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, @@ -774,11 +776,9 @@ class Health { .toList(); } - /// Given an array of [HealthDataPoint]s, this method will return the array - /// without any duplicates. - List removeDuplicates(List points) { - return LinkedHashSet.of(points).toList(); - } + /// 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. @@ -800,39 +800,23 @@ class Health { } /// 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.SLEEP_ASLEEP_CORE: - return 3; - case HealthDataType.SLEEP_ASLEEP_DEEP: - return 4; - case HealthDataType.SLEEP_ASLEEP_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 /// From cc22bd9e2b6c98cfd490dcc6be2b7ce7ea3929ee Mon Sep 17 00:00:00 2001 From: bardram Date: Sat, 30 Mar 2024 11:05:05 +0100 Subject: [PATCH 13/24] fix of workout serialization & fromHealthDataPoint factory --- packages/health/CHANGELOG.md | 5 +++ packages/health/lib/health.dart | 2 +- packages/health/lib/health.g.dart | 8 ++-- .../health/lib/src/health_data_point.dart | 7 +-- ...{data_types.dart => heath_data_types.dart} | 0 packages/health/lib/src/workout_summary.dart | 45 +++++++++---------- packages/health/pubspec.yaml | 2 +- 7 files changed, 32 insertions(+), 37 deletions(-) rename packages/health/lib/src/{data_types.dart => heath_data_types.dart} (100%) diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 56b44a6a4..9e3779d0c 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,3 +1,7 @@ +## 10.1.0 + +* fix of error in `WorkoutSummary` JSON serialization. + ## 10.0.0 * **BREAKING** The plugin now works as a singleton using `Health()` to access it (instead of creating an instance of `HealthFactory`). @@ -6,6 +10,7 @@ * 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. diff --git a/packages/health/lib/health.dart b/packages/health/lib/health.dart index d33ffd440..1c960d54f 100644 --- a/packages/health/lib/health.dart +++ b/packages/health/lib/health.dart @@ -10,7 +10,7 @@ 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'; diff --git a/packages/health/lib/health.g.dart b/packages/health/lib/health.g.dart index 0a85cd51e..1e52a3762 100644 --- a/packages/health/lib/health.g.dart +++ b/packages/health/lib/health.g.dart @@ -530,10 +530,10 @@ Map _$NutritionHealthValueToJson( WorkoutSummary _$WorkoutSummaryFromJson(Map json) => WorkoutSummary( - json['workout_type'] as String, - json['total_distance'] as num, - json['total_energy_burned'] as num, - json['total_steps'] as num, + 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) => diff --git a/packages/health/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index 8d75f76a3..a734aa690 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -122,12 +122,7 @@ class HealthDataPoint { dataPoint["total_distance"] != null || dataPoint["total_energy_burned"] != null || dataPoint["total_steps"] != null) { - workoutSummary = WorkoutSummary( - dataPoint["workout_type"] as String? ?? '', - dataPoint["total_distance"] as num? ?? 0, - dataPoint["total_energy_burned"] as num? ?? 0, - dataPoint["total_steps"] as num? ?? 0, - ); + workoutSummary = WorkoutSummary.fromHealthDataPoint(dataPoint); } return HealthDataPoint( diff --git a/packages/health/lib/src/data_types.dart b/packages/health/lib/src/heath_data_types.dart similarity index 100% rename from packages/health/lib/src/data_types.dart rename to packages/health/lib/src/heath_data_types.dart diff --git a/packages/health/lib/src/workout_summary.dart b/packages/health/lib/src/workout_summary.dart index 81dab385c..c36613802 100644 --- a/packages/health/lib/src/workout_summary.dart +++ b/packages/health/lib/src/workout_summary.dart @@ -1,9 +1,11 @@ part of '../health.dart'; /// A [WorkoutSummary] object store vary metrics of a workout. -/// * totalDistance - The total distance that was traveled during a workout. -/// * totalEnergyBurned - The amount of energy that was burned during a workout. -/// * totalSteps - The count of steps was burned during 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. @@ -18,12 +20,21 @@ class WorkoutSummary { /// The total steps value of the workout. num totalSteps; - WorkoutSummary( - this.workoutType, - this.totalDistance, - this.totalEnergyBurned, - this.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) => @@ -32,22 +43,6 @@ class WorkoutSummary { /// Convert this [HealthDataPoint] to json. Map toJson() => _$WorkoutSummaryToJson(this); - // /// Converts a json object to the [WorkoutSummary] - // factory WorkoutSummary.fromJson(json) => WorkoutSummary( - // json['workoutType'], - // json['totalDistance'], - // json['totalEnergyBurned'], - // json['totalSteps'], - // ); - - // /// Converts the [WorkoutSummary] to a json object - // Map toJson() => { - // 'workoutType': workoutType, - // 'totalDistance': totalDistance, - // 'totalEnergyBurned': totalEnergyBurned, - // 'totalSteps': totalSteps - // }; - @override String toString() => '$runtimeType - ' 'workoutType: $workoutType' diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 578b4b8f2..124f47f12 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -1,6 +1,6 @@ name: health description: Wrapper for HealthKit on iOS and Google Fit and Health Connect on Android. -version: 10.0.0 +version: 10.1.0 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: From 211a2b7873cc926a47d91f0e6cdd291a4061a244 Mon Sep 17 00:00:00 2001 From: bardram Date: Sat, 30 Mar 2024 11:34:47 +0100 Subject: [PATCH 14/24] PR #926 included --- packages/health/CHANGELOG.md | 1 + .../src/main/kotlin/cachet/plugins/health/HealthPlugin.kt | 2 +- packages/health/ios/Classes/SwiftHealthPlugin.swift | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 9e3779d0c..49129463e 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,6 +1,7 @@ ## 10.1.0 * fix of error in `WorkoutSummary` JSON serialization. +* empty value check for calories nutrition, PR [#926](https://github.com/cph-cachet/flutter-plugins/pull/926) ## 10.0.0 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 63323ba88..89affa122 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 @@ -3002,7 +3002,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } is BodyTemperatureRecord -> return listOf( - mapOf( + mapOf(w "value" to record.temperature .inCelsius, diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 3e199d2ed..a85a51197 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -465,8 +465,10 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { 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) From f5fef336768dc651cc2de6463699b5a9e71c2021 Mon Sep 17 00:00:00 2001 From: bardram Date: Sat, 30 Mar 2024 12:47:28 +0100 Subject: [PATCH 15/24] fix of #934 --- packages/health/CHANGELOG.md | 3 ++- .../src/main/kotlin/cachet/plugins/health/HealthPlugin.kt | 2 +- packages/health/pubspec.yaml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 49129463e..eda60a7cf 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,6 +1,7 @@ -## 10.1.0 +## 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 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 89affa122..63323ba88 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 @@ -3002,7 +3002,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } is BodyTemperatureRecord -> return listOf( - mapOf(w + mapOf( "value" to record.temperature .inCelsius, diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 124f47f12..9d89e5104 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -1,6 +1,6 @@ name: health description: Wrapper for HealthKit on iOS and Google Fit and Health Connect on Android. -version: 10.1.0 +version: 10.1.1 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: From 58687eb99cc5815cf4cc3030c6d6e8e5ef04f426 Mon Sep 17 00:00:00 2001 From: bardram Date: Sat, 30 Mar 2024 13:07:48 +0100 Subject: [PATCH 16/24] health - code formatting --- .../kotlin/cachet/plugins/health/HealthPlugin.kt | 8 +++----- packages/health/lib/src/health_plugin.dart | 16 ++++++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) 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 63323ba88..7d156be57 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 @@ -1576,8 +1576,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // / 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. + // date and unit. var dataPoints = dataSet.dataPoints if (!includeManualEntry) { dataPoints = @@ -1587,9 +1586,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ) } } - // / For each data point, extract the contents and send them to Flutter, along with - // date and - // unit. + // 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( diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 66f7caa76..fb8e1b763 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -558,8 +558,11 @@ class Health { /// Fetch a list of health data points based on [types]. Future> getHealthDataFromTypes( - DateTime startTime, DateTime endTime, List types, - {bool includeManualEntry = true}) async { + DateTime startTime, + DateTime endTime, + List types, { + bool includeManualEntry = true, + }) async { List dataPoints = []; for (var type in types) { @@ -613,10 +616,11 @@ class Health { /// Prepares an interval query, i.e. checks if the types are available, etc. Future> _prepareQuery( - DateTime startTime, - DateTime endTime, - HealthDataType dataType, - bool includeManualEntry) async { + DateTime startTime, + DateTime endTime, + HealthDataType dataType, + bool includeManualEntry, + ) async { // Ask for device ID only once _deviceId ??= platformType == PlatformType.ANDROID ? (await _deviceInfo.androidInfo).id From 532cf5d0e01c4e40b741d5d204e026eee2090f86 Mon Sep 17 00:00:00 2001 From: Philipp Bauer Date: Sat, 30 Mar 2024 16:37:04 +0100 Subject: [PATCH 17/24] writeWorkoutData: Add title --- .../kotlin/cachet/plugins/health/HealthPlugin.kt | 3 ++- packages/health/example/lib/main.dart | 12 +++++++----- packages/health/lib/src/health_plugin.dart | 3 +++ 3 files changed, 12 insertions(+), 6 deletions(-) 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 7d156be57..7d482f49a 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 @@ -3765,6 +3765,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : return } val workoutType = workoutTypeMapHealthConnect[type]!! + val title = call.argument("title") ?: type scope.launch { try { @@ -3776,7 +3777,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : endTime = endTime, endZoneOffset = null, exerciseType = workoutType, - title = type, + title = title, ), ) if (totalDistance != null) { diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 4da7b0c7c..31ca955e1 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -185,11 +185,13 @@ class _HealthAppState extends State { // specialized write methods success &= await Health().writeWorkoutData( - HealthWorkoutActivityType.AMERICAN_FOOTBALL, - now.subtract(Duration(minutes: 15)), - now, - totalDistance: 2430, - totalEnergyBurned: 400); + HealthWorkoutActivityType.AMERICAN_FOOTBALL, + now.subtract(Duration(minutes: 15)), + now, + totalDistance: 2430, + totalEnergyBurned: 400, + title: "Random workout name that shows up in Health Connect", + ); success &= await Health().writeBloodPressure(90, 80, earlier, now); success &= await Health().writeMeal( earlier, now, 1000, 50, 25, 50, "Banana", 0.002, MealType.SNACK); diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index fb8e1b763..8038c5715 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -834,6 +834,7 @@ class Health { /// - [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( HealthWorkoutActivityType activityType, DateTime start, @@ -842,6 +843,7 @@ class Health { 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)) { @@ -860,6 +862,7 @@ class Health { 'totalEnergyBurnedUnit': totalEnergyBurnedUnit.name, 'totalDistance': totalDistance, 'totalDistanceUnit': totalDistanceUnit.name, + 'title': title, }; return await _channel.invokeMethod('writeWorkoutData', args) == true; } From 5342c0d0ff2e860db3a63d4d99c4ce975b06d609 Mon Sep 17 00:00:00 2001 From: ikoamu Date: Mon, 1 Apr 2024 00:18:29 +0900 Subject: [PATCH 18/24] resolve conflict --- packages/health/README.md | 376 +++++++++--------- .../ios/Classes/SwiftHealthPlugin.swift | 9 +- packages/health/lib/src/heath_data_types.dart | 3 - 3 files changed, 191 insertions(+), 197 deletions(-) diff --git a/packages/health/README.md b/packages/health/README.md index ec4463048..e37d43a7e 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -191,7 +191,7 @@ Below is a simplified flow of how to use the plugin. ```dart // configure the health plugin before use. Health().configure(useHealthConnectIfAvailable: true); - + // define the types to get var types = [ HealthDataType.STEPS, @@ -281,196 +281,196 @@ points = Health().removeDuplicates(points); 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 | | +| --------------------------- | ----------------------- | ------- | ----------------------- |---------------------------| -------------------------------------- | +| 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 | | ## 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 | | -| BASKETBALL | yes | yes | yes | | -| BIKING | yes | yes | yes | on iOS this is CYCLING, but name changed here to fit with Android | -| BOXING | yes | yes | yes | | -| CRICKET | yes | yes | yes | | -| CURLING | yes | yes | | | -| ELLIPTICAL | yes | yes | yes | | -| FENCING | yes | yes | yes | | -| AMERICAN_FOOTBALL | yes | yes | yes | | -| AUSTRALIAN_FOOTBALL | yes | yes | yes | | -| SOCCER | yes | yes | | | -| GOLF | yes | yes | yes | | -| GYMNASTICS | yes | yes | yes | | -| HANDBALL | yes | yes | yes | | -| HIGH_INTENSITY_INTERVAL_TRAINING | yes | yes | yes | | -| HIKING | yes | yes | yes | | -| HOCKEY | yes | yes | | | -| SKATING | yes | yes | yes | On iOS this is skating_sports | -| JUMP_ROPE | yes | yes | | | -| KICKBOXING | yes | yes | | | -| MARTIAL_ARTS | yes | yes | yes | | -| PILATES | yes | yes | yes | | -| RACQUETBALL | yes | yes | yes | | -| RUGBY | yes | yes | yes | | -| RUNNING | yes | yes | yes | | -| ROWING | yes | yes | yes | | -| SAILING | yes | yes | yes | | -| CROSS_COUNTRY_SKIING | yes | yes | | | -| DOWNHILL_SKIING | yes | yes | | | -| SNOWBOARDING | yes | yes | yes | | -| SOFTBALL | yes | yes | yes | | -| SQUASH | yes | yes | yes | | -| STAIR_CLIMBING | yes | yes | yes | | -| SWIMMING | yes | yes | | | -| TABLE_TENNIS | yes | yes | yes | | -| TENNIS | yes | yes | yes | | -| VOLLEYBALL | yes | yes | yes | | -| WALKING | yes | yes | yes | | -| WATER_POLO | yes | yes | yes | | -| YOGA | yes | yes | yes | | -| BOWLING | yes | | | | -| CROSS_TRAINING | yes | | | | -| TRACK_AND_FIELD | yes | | | | -| DISC_SPORTS | yes | | | | -| LACROSSE | yes | | | | -| PREPARATION_AND_RECOVERY | yes | | | | -| FLEXIBILITY | yes | | | | -| COOLDOWN | yes | | | | -| WHEELCHAIR_WALK_PACE | yes | | | | -| WHEELCHAIR_RUN_PACE | yes | | | | -| HAND_CYCLING | yes | | | | -| CORE_TRAINING | yes | | | | -| FUNCTIONAL_STRENGTH_TRAINING | yes | | | | -| TRADITIONAL_STRENGTH_TRAINING | yes | | | | -| MIXED_CARDIO | yes | | | | -| STAIRS | yes | | | | -| STEP_TRAINING | yes | | | | -| FITNESS_GAMING | yes | | | | -| BARRE | yes | | | | -| CARDIO_DANCE | yes | | | | -| SOCIAL_DANCE | yes | | | | -| MIND_AND_BODY | yes | | | | -| PICKLEBALL | yes | | | | -| CLIMBING | yes | | | | -| EQUESTRIAN_SPORTS | yes | | | | -| FISHING | yes | | | | -| HUNTING | yes | | | | -| PLAY | yes | | | | -| SNOW_SPORTS | yes | | | | -| PADDLE_SPORTS | yes | | | | -| SURFING_SPORTS | yes | | | | -| WATER_FITNESS | yes | | | | -| WATER_SPORTS | yes | | | | -| TAI_CHI | yes | | | | -| WRESTLING | yes | | | | -| AEROBICS | | yes | | | -| BIATHLON | | yes | | | -| CALISTHENICS | | yes | yes | | -| CIRCUIT_TRAINING | | yes | | | -| CROSS_FIT | | yes | | | -| DANCING | | yes | yes | | -| DIVING | | yes | | | -| ELEVATOR | | yes | | | -| ERGOMETER | | yes | | | -| ESCALATOR | | yes | | | -| FRISBEE_DISC | | yes | yes | | -| GARDENING | | yes | | | -| GUIDED_BREATHING | | yes | yes | | -| HORSEBACK_RIDING | | yes | | | -| HOUSEWORK | | yes | | | -| INTERVAL_TRAINING | | yes | | | -| IN_VEHICLE | | yes | | | -| KAYAKING | | yes | | | -| KETTLEBELL_TRAINING | | yes | | | -| KICK_SCOOTER | | yes | | | -| KITE_SURFING | | yes | | | -| MEDITATION | | yes | | | -| MIXED_MARTIAL_ARTS | | yes | | | -| P90X | | yes | | | -| PARAGLIDING | | yes | yes | | -| POLO | | yes | | | -| ROCK_CLIMBING | (yes) | yes | yes | on iOS this will be stored as CLIMBING | -| RUNNING_JOGGING | (yes) | yes | | on iOS this will be stored as RUNNING | -| RUNNING_SAND | (yes) | yes | | on iOS this will be stored as RUNNING | -| RUNNING_TREADMILL | (yes) | yes | yes | on iOS this will be stored as RUNNING | -| SCUBA_DIVING | | yes | yes | | -| SKATING_CROSS | (yes) | yes | | on iOS this will be stored as SKATING | -| SKATING_INDOOR | (yes) | yes | | on iOS this will be stored as SKATING | -| SKATING_INLINE | (yes) | yes | | on iOS this will be stored as SKATING | -| SKIING_BACK_COUNTRY | | yes | | | -| SKIING_KITE | | yes | | | -| SKIING_ROLLER | | yes | | | -| SLEDDING | | yes | | | -| STAIR_CLIMBING_MACHINE | | yes | yes | | -| STANDUP_PADDLEBOARDING | | yes | | | -| STILL | | yes | | | -| STRENGTH_TRAINING | | yes | yes | | -| SURFING | | yes | yes | | -| SWIMMING_OPEN_WATER | | yes | yes | | -| SWIMMING_POOL | | yes | yes | | -| TEAM_SPORTS | | yes | | | -| TILTING | | yes | | | -| TREADMILL | | yes | | | -| VOLLEYBALL_BEACH | | yes | | | -| VOLLEYBALL_INDOOR | | yes | | | -| WAKEBOARDING | | yes | | | -| WALKING_FITNESS | | yes | | | -| WALKING_NORDIC | | yes | | | -| WALKING_STROLLER | | yes | | | -| WALKING_TREADMILL | | yes | | | -| WEIGHTLIFTING | | yes | yes | | -| WHEELCHAIR | | yes | yes | | -| WINDSURFING | | yes | | | -| ZUMBA | | yes | | | -| OTHER | yes | yes | | | +| -------------------------------- | ------- | ----------------------- | ---------------------------- | ----------------------------------------------------------------- | +| ARCHERY | yes | yes | | | +| BADMINTON | yes | yes | yes | | +| BASEBALL | yes | yes | yes | | +| BASKETBALL | yes | yes | yes | | +| BIKING | yes | yes | yes | on iOS this is CYCLING, but name changed here to fit with Android | +| BOXING | yes | yes | yes | | +| CRICKET | yes | yes | yes | | +| CURLING | yes | yes | | | +| ELLIPTICAL | yes | yes | yes | | +| FENCING | yes | yes | yes | | +| AMERICAN_FOOTBALL | yes | yes | yes | | +| AUSTRALIAN_FOOTBALL | yes | yes | yes | | +| SOCCER | yes | yes | | | +| GOLF | yes | yes | yes | | +| GYMNASTICS | yes | yes | yes | | +| HANDBALL | yes | yes | yes | | +| HIGH_INTENSITY_INTERVAL_TRAINING | yes | yes | yes | | +| HIKING | yes | yes | yes | | +| HOCKEY | yes | yes | | | +| SKATING | yes | yes | yes | On iOS this is skating_sports | +| JUMP_ROPE | yes | yes | | | +| KICKBOXING | yes | yes | | | +| MARTIAL_ARTS | yes | yes | yes | | +| PILATES | yes | yes | yes | | +| RACQUETBALL | yes | yes | yes | | +| RUGBY | yes | yes | yes | | +| RUNNING | yes | yes | yes | | +| ROWING | yes | yes | yes | | +| SAILING | yes | yes | yes | | +| CROSS_COUNTRY_SKIING | yes | yes | | | +| DOWNHILL_SKIING | yes | yes | | | +| SNOWBOARDING | yes | yes | yes | | +| SOFTBALL | yes | yes | yes | | +| SQUASH | yes | yes | yes | | +| STAIR_CLIMBING | yes | yes | yes | | +| SWIMMING | yes | yes | | | +| TABLE_TENNIS | yes | yes | yes | | +| TENNIS | yes | yes | yes | | +| VOLLEYBALL | yes | yes | yes | | +| WALKING | yes | yes | yes | | +| WATER_POLO | yes | yes | yes | | +| YOGA | yes | yes | yes | | +| BOWLING | yes | | | | +| CROSS_TRAINING | yes | | | | +| TRACK_AND_FIELD | yes | | | | +| DISC_SPORTS | yes | | | | +| LACROSSE | yes | | | | +| PREPARATION_AND_RECOVERY | yes | | | | +| FLEXIBILITY | yes | | | | +| COOLDOWN | yes | | | | +| WHEELCHAIR_WALK_PACE | yes | | | | +| WHEELCHAIR_RUN_PACE | yes | | | | +| HAND_CYCLING | yes | | | | +| CORE_TRAINING | yes | | | | +| FUNCTIONAL_STRENGTH_TRAINING | yes | | | | +| TRADITIONAL_STRENGTH_TRAINING | yes | | | | +| MIXED_CARDIO | yes | | | | +| STAIRS | yes | | | | +| STEP_TRAINING | yes | | | | +| FITNESS_GAMING | yes | | | | +| BARRE | yes | | | | +| CARDIO_DANCE | yes | | | | +| SOCIAL_DANCE | yes | | | | +| MIND_AND_BODY | yes | | | | +| PICKLEBALL | yes | | | | +| CLIMBING | yes | | | | +| EQUESTRIAN_SPORTS | yes | | | | +| FISHING | yes | | | | +| HUNTING | yes | | | | +| PLAY | yes | | | | +| SNOW_SPORTS | yes | | | | +| PADDLE_SPORTS | yes | | | | +| SURFING_SPORTS | yes | | | | +| WATER_FITNESS | yes | | | | +| WATER_SPORTS | yes | | | | +| TAI_CHI | yes | | | | +| WRESTLING | yes | | | | +| AEROBICS | | yes | | | +| BIATHLON | | yes | | | +| CALISTHENICS | | yes | yes | | +| CIRCUIT_TRAINING | | yes | | | +| CROSS_FIT | | yes | | | +| DANCING | | yes | yes | | +| DIVING | | yes | | | +| ELEVATOR | | yes | | | +| ERGOMETER | | yes | | | +| ESCALATOR | | yes | | | +| FRISBEE_DISC | | yes | yes | | +| GARDENING | | yes | | | +| GUIDED_BREATHING | | yes | yes | | +| HORSEBACK_RIDING | | yes | | | +| HOUSEWORK | | yes | | | +| INTERVAL_TRAINING | | yes | | | +| IN_VEHICLE | | yes | | | +| KAYAKING | | yes | | | +| KETTLEBELL_TRAINING | | yes | | | +| KICK_SCOOTER | | yes | | | +| KITE_SURFING | | yes | | | +| MEDITATION | | yes | | | +| MIXED_MARTIAL_ARTS | | yes | | | +| P90X | | yes | | | +| PARAGLIDING | | yes | yes | | +| POLO | | yes | | | +| ROCK_CLIMBING | (yes) | yes | yes | on iOS this will be stored as CLIMBING | +| RUNNING_JOGGING | (yes) | yes | | on iOS this will be stored as RUNNING | +| RUNNING_SAND | (yes) | yes | | on iOS this will be stored as RUNNING | +| RUNNING_TREADMILL | (yes) | yes | yes | on iOS this will be stored as RUNNING | +| SCUBA_DIVING | | yes | yes | | +| SKATING_CROSS | (yes) | yes | | on iOS this will be stored as SKATING | +| SKATING_INDOOR | (yes) | yes | | on iOS this will be stored as SKATING | +| SKATING_INLINE | (yes) | yes | | on iOS this will be stored as SKATING | +| SKIING_BACK_COUNTRY | | yes | | | +| SKIING_KITE | | yes | | | +| SKIING_ROLLER | | yes | | | +| SLEDDING | | yes | | | +| STAIR_CLIMBING_MACHINE | | yes | yes | | +| STANDUP_PADDLEBOARDING | | yes | | | +| STILL | | yes | | | +| STRENGTH_TRAINING | | yes | yes | | +| SURFING | | yes | yes | | +| SWIMMING_OPEN_WATER | | yes | yes | | +| SWIMMING_POOL | | yes | yes | | +| TEAM_SPORTS | | yes | | | +| TILTING | | yes | | | +| TREADMILL | | yes | | | +| VOLLEYBALL_BEACH | | yes | | | +| VOLLEYBALL_INDOOR | | yes | | | +| WAKEBOARDING | | yes | | | +| WALKING_FITNESS | | yes | | | +| WALKING_NORDIC | | yes | | | +| WALKING_STROLLER | | yes | | | +| WALKING_TREADMILL | | yes | | | +| WEIGHTLIFTING | | yes | yes | | +| WHEELCHAIR | | yes | yes | | +| WINDSURFING | | yes | | | +| ZUMBA | | yes | | | +| OTHER | yes | yes | | | \ No newline at end of file diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 8e7e9845e..efc83f371 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -70,7 +70,6 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let HEADACHE_SEVERE = "HEADACHE_SEVERE" let ELECTROCARDIOGRAM = "ELECTROCARDIOGRAM" let NUTRITION = "NUTRITION" - let STAND_TIME = "STAND_TIME" // Health Unit types // MOLE_UNIT_WITH_MOLAR_MASS, // requires molar mass input - not supported yet @@ -430,7 +429,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { 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( [blood_pressure_sample], withCompletion: { (success, error) in @@ -616,8 +615,6 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { var predicate = HKQuery.predicateForSamples( withStart: dateFrom, end: dateTo, options: .strictStartDate) - let watchPredicate = HKQuery.predicateForObjects(withDeviceProperty: HKDevicePropertyKeyModel, allowedValues: ["Watch"]) - let combinedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, watchPredicate]) if (!includeManualEntry) { let manualPredicate = NSPredicate(format: "metadata.%K != YES", HKMetadataKeyWasUserEntered) predicate = NSCompoundPredicate(type: .and, subpredicates: [predicate, manualPredicate]) @@ -625,7 +622,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) let query = HKSampleQuery( - sampleType: dataType, predicate: combinedPredicate, limit: limit, sortDescriptors: [sortDescriptor] + sampleType: dataType, predicate: predicate, limit: limit, sortDescriptors: [sortDescriptor] ) { [self] x, samplesOrNil, error in @@ -1202,7 +1199,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { dataTypesDict[WORKOUT] = HKSampleType.workoutType() dataTypesDict[NUTRITION] = HKSampleType.correlationType( forIdentifier: .food)! - dataTypesDict[STAND_TIME] = HKSampleType.quantityType(forIdentifier: .appleStandTime) + healthDataTypes = Array(dataTypesDict.values) } diff --git a/packages/health/lib/src/heath_data_types.dart b/packages/health/lib/src/heath_data_types.dart index 4e683dfff..37ca9b5cd 100644 --- a/packages/health/lib/src/heath_data_types.dart +++ b/packages/health/lib/src/heath_data_types.dart @@ -56,7 +56,6 @@ enum HealthDataType { HEADACHE_SEVERE, HEADACHE_UNSPECIFIED, NUTRITION, - STAND_TIME, // Heart Rate events (specific to Apple Watch) HIGH_HEART_RATE_EVENT, @@ -131,7 +130,6 @@ const List dataTypeKeysIOS = [ HealthDataType.HEADACHE_UNSPECIFIED, HealthDataType.ELECTROCARDIOGRAM, HealthDataType.NUTRITION, - HealthDataType.STAND_TIME, ]; /// List of data types available on Android @@ -236,7 +234,6 @@ const Map dataTypeToUnit = { HealthDataType.ELECTROCARDIOGRAM: HealthDataUnit.VOLT, HealthDataType.NUTRITION: HealthDataUnit.NO_UNIT, - HealthDataType.STAND_TIME: HealthDataUnit.MINUTE, // Health Connect HealthDataType.TOTAL_CALORIES_BURNED: HealthDataUnit.KILOCALORIE, From f40f30a6ba9ea6d824c5cd4b4f1961e55485b7dd Mon Sep 17 00:00:00 2001 From: ikoamu Date: Mon, 1 Apr 2024 00:21:03 +0900 Subject: [PATCH 19/24] Add missing newline at end of file --- packages/health/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/health/README.md b/packages/health/README.md index e37d43a7e..9a6ebc06c 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -473,4 +473,4 @@ The plugin supports the following [`HealthWorkoutActivityType`](https://pub.dev/ | WHEELCHAIR | | yes | yes | | | WINDSURFING | | yes | | | | ZUMBA | | yes | | | -| OTHER | yes | yes | | | \ No newline at end of file +| OTHER | yes | yes | | | From 6fcf0bd3a821099e007f2161b4708d991187da06 Mon Sep 17 00:00:00 2001 From: Philipp Bauer Date: Tue, 2 Apr 2024 15:45:18 +0200 Subject: [PATCH 20/24] Add installHealthConnect --- .../cachet/plugins/health/HealthPlugin.kt | 17 +++++++++++++++++ packages/health/example/lib/main.dart | 13 +++++++++++++ packages/health/lib/src/health_plugin.dart | 14 ++++++++++++++ 3 files changed, 44 insertions(+) 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 7d156be57..a4d954c6f 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 @@ -2382,6 +2383,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : /** Handle calls from the MethodChannel */ override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { + "installHealthConnect" -> installHealthConnect(call, result) "useHealthConnectIfAvailable" -> useHealthConnectIfAvailable(call, result) "hasPermissions" -> hasPermissions(call, result) "requestAuthorization" -> requestAuthorization(call, result) @@ -2444,6 +2446,21 @@ class HealthPlugin(private var channel: MethodChannel? = null) : healthConnectAvailable = healthConnectStatus == HealthConnectClient.SDK_AVAILABLE } + 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) + } + ) + result.success(null) + } + fun useHealthConnectIfAvailable(call: MethodCall, result: Result) { useHealthConnectIfAvailable = true result.success(null) diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 4da7b0c7c..a1767cb3a 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:health/health.dart'; @@ -69,6 +70,10 @@ class _HealthAppState extends State { super.initState(); } + Future installHealthConnect() async { + await Health().installHealthConnect(); + } + /// Authorize, i.e. get permissions to access relevant health data. Future authorize() async { // If we are trying to read Step Count, Workout, Sleep or other data that requires @@ -330,6 +335,14 @@ 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), diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index fb8e1b763..4ee6eb1c5 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -61,6 +61,20 @@ class Health { } } + /// ANDROID ONLY: + /// Prompt the user to install the Health Connect app via the installed store (most likely Play Store). + Future installHealthConnect() async { + try { + if (platformType != PlatformType.ANDROID) { + throw UnsupportedError( + 'installHealthConnect is only available on Android'); + } + await _channel.invokeMethod('installHealthConnect'); + } catch (e) { + debugPrint('$runtimeType - Exception in revokePermissions(): $e'); + } + } + /// Is this plugin using Health Connect (true) or Google Fit (false)? /// /// This is set in the [configure] method. From ddbb06d30218135df116c297e45bbd19d4a61c98 Mon Sep 17 00:00:00 2001 From: Philipp Bauer Date: Tue, 2 Apr 2024 18:25:21 +0200 Subject: [PATCH 21/24] Add getHealthConnectSdkStatus --- .../cachet/plugins/health/HealthPlugin.kt | 26 ++++++++++++----- packages/health/example/lib/main.dart | 26 +++++++++++++++++ packages/health/lib/src/functions.dart | 29 +++++++++++++++++++ packages/health/lib/src/health_plugin.dart | 20 +++++++++++++ 4 files changed, 93 insertions(+), 8 deletions(-) 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 7d156be57..385097fa1 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 @@ -2383,6 +2383,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { "useHealthConnectIfAvailable" -> useHealthConnectIfAvailable(call, result) + "getHealthConnectSdkStatus" -> getHealthConnectSdkStatus(call, result) "hasPermissions" -> hasPermissions(call, result) "requestAuthorization" -> requestAuthorization(call, result) "revokePermissions" -> revokePermissions(call, result) @@ -2408,15 +2409,13 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 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() { @@ -2449,6 +2448,17 @@ class HealthPlugin(private var channel: MethodChannel? = null) : result.success(null) } + 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()!! diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 4da7b0c7c..0e282c133 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:health/health.dart'; @@ -29,6 +30,7 @@ enum AppState { DATA_NOT_ADDED, DATA_NOT_DELETED, STEPS_READY, + HEALTH_CONNECT_STATUS, } class _HealthAppState extends State { @@ -102,6 +104,18 @@ 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 { setState(() => _state = AppState.FETCHING_DATA); @@ -295,6 +309,14 @@ class _HealthAppState extends State { style: ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.blue))), + if (Platform.isAndroid) + TextButton( + onPressed: getHealthConnectSdkStatus, + child: Text("getHealthConnectSdkStatus", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), TextButton( onPressed: fetchData, child: Text("Fetch Data", @@ -412,6 +434,9 @@ class _HealthAppState extends State { 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.'); @@ -435,5 +460,6 @@ class _HealthAppState extends State { AppState.DATA_NOT_ADDED => _dataNotAdded, AppState.DATA_NOT_DELETED => _dataNotDeleted, AppState.STEPS_READY => _stepsFetched, + AppState.HEALTH_CONNECT_STATUS => _contentHealthConnectStatus, }; } diff --git a/packages/health/lib/src/functions.dart b/packages/health/lib/src/functions.dart index 7d1d8a80e..0b4cbdc08 100644 --- a/packages/health/lib/src/functions.dart +++ b/packages/health/lib/src/functions.dart @@ -18,3 +18,32 @@ class HealthException implements Exception { /// A list of supported platforms. enum PlatformType { IOS, ANDROID } + +/// The status of 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_plugin.dart b/packages/health/lib/src/health_plugin.dart index fb8e1b763..61c831bea 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -120,6 +120,26 @@ class Health { }); } + /// 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) + /// + /// Not implemented on iOS as Health Connect doesn't exist at all there. + Future getHealthConnectSdkStatus() async { + try { + if (platformType == PlatformType.IOS) { + 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; + } + } + /// Revokes permissions of all types. /// /// Uses `disableFit()` on Google Fit. From d96cf6a00591af4e42b38c854da6716c9e868c3f Mon Sep 17 00:00:00 2001 From: bardram Date: Wed, 3 Apr 2024 13:02:56 +0200 Subject: [PATCH 22/24] PR #941 #943 #938 merged --- packages/health/CHANGELOG.md | 14 +- packages/health/example/lib/main.dart | 155 ++++++++---- packages/health/lib/src/functions.dart | 6 +- packages/health/lib/src/health_plugin.dart | 263 +++++++++++---------- packages/health/pubspec.yaml | 2 +- 5 files changed, 266 insertions(+), 174 deletions(-) diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index eda60a7cf..da6965245 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,8 +1,16 @@ +## 10.2.0 + +* Using named parameters in most methods for consistency. +* 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) + ## 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) +* 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 diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 1ce79c152..1dd7d8133 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -38,11 +38,12 @@ class _HealthAppState extends State { AppState _state = AppState.DATA_NOT_FETCHED; int _nofSteps = 0; - // Define the types to get. - - // Use the entire list on e.g. Android. - static final types = dataTypesIOS; - // static final types = dataTypesAndroid; + // All types available depending on platform (iOS ot Android). + List get types => (Platform.isAndroid) + ? dataTypesAndroid + : (Platform.isIOS) + ? dataTypesIOS + : []; // // Or specify specific types // static final types = [ @@ -59,10 +60,12 @@ 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. @@ -71,6 +74,7 @@ class _HealthAppState extends State { super.initState(); } + /// Install Google Health Connect on this phone. Future installHealthConnect() async { await Health().installHealthConnect(); } @@ -133,8 +137,11 @@ 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.' : ''}'); @@ -171,48 +178,102 @@ class _HealthAppState extends State { bool success = true; // misc. health data examples using the writeHealthData() method - success &= await Health() - .writeHealthData(1.925, HealthDataType.HEIGHT, earlier, now); - success &= - await Health().writeHealthData(90, HealthDataType.WEIGHT, now, 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); + 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(0.0, HealthDataType.SLEEP_REM, earlier, now); - success &= await Health() - .writeHealthData(0.0, HealthDataType.SLEEP_ASLEEP, earlier, now); - success &= await Health() - .writeHealthData(0.0, HealthDataType.SLEEP_AWAKE, earlier, now); - success &= await Health() - .writeHealthData(0.0, HealthDataType.SLEEP_DEEP, earlier, now); + 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( - HealthWorkoutActivityType.AMERICAN_FOOTBALL, - now.subtract(Duration(minutes: 15)), - now, + 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, - title: "Random workout name that shows up in Health Connect", ); - success &= await Health().writeBloodPressure(90, 80, earlier, now); + success &= await Health().writeBloodPressure( + systolic: 90, + diastolic: 80, + startTime: now, + ); success &= await Health().writeMeal( - earlier, now, 1000, 50, 25, 50, "Banana", 0.002, MealType.SNACK); + 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]; @@ -242,7 +303,11 @@ class _HealthAppState extends State { 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(() { @@ -310,15 +375,15 @@ 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("getHealthConnectSdkStatus", + child: Text("Check Health Connect Status", style: TextStyle(color: Colors.white)), style: ButtonStyle( backgroundColor: diff --git a/packages/health/lib/src/functions.dart b/packages/health/lib/src/functions.dart index 0b4cbdc08..4cb6a2b1c 100644 --- a/packages/health/lib/src/functions.dart +++ b/packages/health/lib/src/functions.dart @@ -19,10 +19,10 @@ class HealthException implements Exception { /// A list of supported platforms. enum PlatformType { IOS, ANDROID } -/// The status of Health Connect. +/// The status of Google Health Connect. /// -/// NOTE: -/// The enum order is arbitrary. If you need the native value, use [nativeValue] and not the index. +/// **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 diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 50a08a0d9..317e9db4e 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -27,7 +27,7 @@ class Health { String? _deviceId; final _deviceInfo = DeviceInfoPlugin(); - late bool _useHealthConnectIfAvailable; + bool _useHealthConnectIfAvailable = false; Health._() { _registerFromJsonFunctions(); @@ -61,20 +61,6 @@ class Health { } } - /// ANDROID ONLY: - /// Prompt the user to install the Health Connect app via the installed store (most likely Play Store). - Future installHealthConnect() async { - try { - if (platformType != PlatformType.ANDROID) { - throw UnsupportedError( - 'installHealthConnect is only available on Android'); - } - await _channel.invokeMethod('installHealthConnect'); - } catch (e) { - debugPrint('$runtimeType - Exception in revokePermissions(): $e'); - } - } - /// Is this plugin using Health Connect (true) or Google Fit (false)? /// /// This is set in the [configure] method. @@ -134,12 +120,30 @@ class Health { }); } + /// 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) { + throw UnsupportedError( + 'Revoke permissions is not supported on iOS. Please revoke permissions manually in the settings.'); + } + await _channel.invokeMethod('revokePermissions'); + return; + } catch (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) /// - /// Not implemented on iOS as Health Connect doesn't exist at all there. + /// Android only. Future getHealthConnectSdkStatus() async { try { if (platformType == PlatformType.IOS) { @@ -154,21 +158,19 @@ class Health { } } - /// Revokes permissions of all types. - /// - /// Uses `disableFit()` on Google Fit. + /// Prompt the user to install the Health Connect app via the installed store + /// (most likely Play Store). /// - /// Not implemented on iOS as there is no way to programmatically remove access. - Future revokePermissions() async { + /// Android only. + Future installHealthConnect() async { try { - if (platformType == PlatformType.IOS) { + if (platformType != PlatformType.ANDROID) { throw UnsupportedError( - 'Revoke permissions is not supported on iOS. Please revoke permissions manually in the settings.'); + 'installHealthConnect is only available on Android'); } - await _channel.invokeMethod('revokePermissions'); - return; + await _channel.invokeMethod('installHealthConnect'); } catch (e) { - debugPrint('$runtimeType - Exception in revokePermissions(): $e'); + debugPrint('$runtimeType - Exception in installHealthConnect(): $e'); } } @@ -327,28 +329,30 @@ class Health { /// 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. + /// * [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( - double value, - HealthDataType type, - DateTime startTime, - DateTime endTime, { + Future writeHealthData({ + required double value, HealthDataUnit? unit, + required HealthDataType type, + required DateTime startTime, + DateTime? endTime, }) async { if (type == HealthDataType.WORKOUT) { throw ArgumentError( "Adding workouts should be done using the writeWorkoutData method."); } + endTime ??= startTime; if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); } @@ -405,11 +409,12 @@ class Health { /// 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( - HealthDataType type, - DateTime startTime, - DateTime endTime, - ) async { + 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"); } @@ -435,13 +440,14 @@ class Health { /// * [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. - Future writeBloodPressure( - int systolic, - int diastolic, - DateTime startTime, - DateTime endTime, - ) async { + /// 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"); } @@ -463,18 +469,19 @@ class Health { /// * [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. + /// * [startTime] - the start time when this [saturation] is measured. /// Must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when this [value] is measured. + /// * [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. - Future writeBloodOxygen( - double saturation, - DateTime startTime, - DateTime endTime, { + /// 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"); } @@ -482,7 +489,10 @@ class Health { if (platformType == PlatformType.IOS) { success = await writeHealthData( - saturation, HealthDataType.BLOOD_OXYGEN, startTime, endTime); + value: saturation, + type: HealthDataType.BLOOD_OXYGEN, + startTime: startTime, + endTime: endTime); } else if (platformType == PlatformType.ANDROID) { Map args = { 'value': saturation, @@ -496,30 +506,32 @@ class Health { 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, - double? caffeine, - MealType mealType) async { + /// * [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"); } @@ -544,22 +556,25 @@ class Health { /// 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) { @@ -571,6 +586,7 @@ class Health { throw ArgumentError( "frequencies, leftEarSensitivities and rightEarSensitivities need to be of the same length"); } + endTime ??= startTime; if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); } @@ -591,10 +607,10 @@ class Health { } /// Fetch a list of health data points based on [types]. - Future> getHealthDataFromTypes( - DateTime startTime, - DateTime endTime, - List types, { + Future> getHealthDataFromTypes({ + required List types, + required DateTime startTime, + required DateTime endTime, bool includeManualEntry = true, }) async { List dataPoints = []; @@ -615,11 +631,11 @@ class Health { /// Fetch a list of health data points based on [types]. Future> getHealthIntervalDataFromTypes( - DateTime startDate, - DateTime endDate, - List types, - int interval, - {bool includeManualEntry = true}) async { + {required DateTime startDate, + required DateTime endDate, + required List types, + required int interval, + bool includeManualEntry = true}) async { List dataPoints = []; for (var type in types) { @@ -632,10 +648,10 @@ class Health { } /// Fetch a list of health data points based on [types]. - Future> getHealthAggregateDataFromTypes( - DateTime startDate, - DateTime endDate, - List types, { + Future> getHealthAggregateDataFromTypes({ + required List types, + required DateTime startDate, + required DateTime endDate, int activitySegmentDuration = 1, bool includeManualEntry = true, }) async { @@ -856,23 +872,26 @@ class Health { "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. - /// - [title] The title of the workout. *ONLY FOR HEALTH CONNECT* Default value is the [activityType], e.g. "STRENGTH_TRAINING" - 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, diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 9d89e5104..cc118c503 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -1,6 +1,6 @@ name: health description: Wrapper for HealthKit on iOS and Google Fit and Health Connect on Android. -version: 10.1.1 +version: 10.2.0 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: From 122b3aec2ba73f9d279d887bde43cd04b27f7d62 Mon Sep 17 00:00:00 2001 From: bardram Date: Wed, 3 Apr 2024 13:48:03 +0200 Subject: [PATCH 23/24] PR #919 & support for HealthPlatformType --- packages/health/CHANGELOG.md | 4 +- packages/health/README.md | 28 ++++++++-- packages/health/example/lib/main.dart | 34 +++++------ packages/health/lib/health.g.dart | 16 +++--- packages/health/lib/src/functions.dart | 3 - .../health/lib/src/health_data_point.dart | 29 +++++----- packages/health/lib/src/health_plugin.dart | 56 +++++++++---------- packages/health/lib/src/heath_data_types.dart | 8 +-- 8 files changed, 98 insertions(+), 80 deletions(-) diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index da6965245..0d464dcb2 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,10 +1,12 @@ ## 10.2.0 * Using named parameters in most methods for consistency. -* Improved support for Google Health Connect +* 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 diff --git a/packages/health/README.md b/packages/health/README.md index 9a6ebc06c..30f87c6ca 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -234,17 +234,35 @@ 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; ``` -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. +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 diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 1dd7d8133..84e93cf07 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -7,10 +7,6 @@ import 'package:health_example/util.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:carp_serializable/carp_serializable.dart'; -// /// A connivent function to convert a Dart object into a formatted JSON string. -// String toJsonString(Object? object) => -// const JsonEncoder.withIndent(' ').convert(object); - void main() => runApp(HealthApp()); class HealthApp extends StatefulWidget { @@ -135,23 +131,23 @@ class _HealthAppState extends State { // Clear old data points _healthDataList.clear(); - try { - // fetch health data - List healthData = await Health().getHealthDataFromTypes( - types: types, - startTime: yesterday, - endTime: now, - ); + // try { + // fetch health data + 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.' : ''}'); + 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) { - debugPrint("Exception in getHealthDataFromTypes: $error"); - } + // save all the new data points (only the first 100) + _healthDataList.addAll( + (healthData.length < 100) ? healthData : healthData.sublist(0, 100)); + // } catch (error) { + // debugPrint("Exception in getHealthDataFromTypes: $error"); + // } // filter out duplicates _healthDataList = Health().removeDuplicates(_healthDataList); diff --git a/packages/health/lib/health.g.dart b/packages/health/lib/health.g.dart index 1e52a3762..8b23ccc0e 100644 --- a/packages/health/lib/health.g.dart +++ b/packages/health/lib/health.g.dart @@ -13,8 +13,9 @@ HealthDataPoint _$HealthDataPointFromJson(Map json) => unit: $enumDecode(_$HealthDataUnitEnumMap, json['unit']), dateFrom: DateTime.parse(json['date_from'] as String), dateTo: DateTime.parse(json['date_to'] as String), - platform: $enumDecode(_$PlatformTypeEnumMap, json['platform']), - deviceId: json['device_id'] 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, @@ -31,8 +32,8 @@ Map _$HealthDataPointToJson(HealthDataPoint instance) { 'unit': _$HealthDataUnitEnumMap[instance.unit]!, 'date_from': instance.dateFrom.toIso8601String(), 'date_to': instance.dateTo.toIso8601String(), - 'platform': _$PlatformTypeEnumMap[instance.platform]!, - 'device_id': instance.deviceId, + 'source_platform': _$HealthPlatformTypeEnumMap[instance.sourcePlatform]!, + 'source_device_id': instance.sourceDeviceId, 'source_id': instance.sourceId, 'source_name': instance.sourceName, 'is_manual_entry': instance.isManualEntry, @@ -163,9 +164,10 @@ const _$HealthDataUnitEnumMap = { HealthDataUnit.NO_UNIT: 'NO_UNIT', }; -const _$PlatformTypeEnumMap = { - PlatformType.IOS: 'IOS', - PlatformType.ANDROID: 'ANDROID', +const _$HealthPlatformTypeEnumMap = { + HealthPlatformType.appleHealth: 'appleHealth', + HealthPlatformType.googleFit: 'googleFit', + HealthPlatformType.googleHealthConnect: 'googleHealthConnect', }; HealthValue _$HealthValueFromJson(Map json) => diff --git a/packages/health/lib/src/functions.dart b/packages/health/lib/src/functions.dart index 4cb6a2b1c..cc17b988c 100644 --- a/packages/health/lib/src/functions.dart +++ b/packages/health/lib/src/functions.dart @@ -16,9 +16,6 @@ class HealthException implements Exception { "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, diff --git a/packages/health/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index a734aa690..3a6f65f95 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -1,5 +1,8 @@ part of '../health.dart'; +/// Types of health platforms. +enum HealthPlatformType { appleHealth, googleFit, googleHealthConnect } + /// A [HealthDataPoint] object corresponds to a data point capture from /// Apple HealthKit or Google Fit or Google Health Connect with a [HealthValue] /// as value. @@ -26,11 +29,11 @@ class HealthDataPoint { /// The end of the time interval. DateTime dateTo; - /// The software platform of the data point. - PlatformType platform; + /// The health platform that this data point was fetched. + HealthPlatformType sourcePlatform; /// The id of the device from which the data point was fetched. - String deviceId; + String sourceDeviceId; /// The id of the source from which the data point was fetched. String sourceId; @@ -50,8 +53,8 @@ class HealthDataPoint { required this.unit, required this.dateFrom, required this.dateTo, - required this.platform, - required this.deviceId, + required this.sourcePlatform, + required this.sourceDeviceId, required this.sourceId, required this.sourceName, this.isManualEntry = false, @@ -131,8 +134,8 @@ class HealthDataPoint { unit: unit, dateFrom: from, dateTo: to, - platform: Health().platformType, - deviceId: Health().deviceId, + sourcePlatform: Health().platformType, + sourceDeviceId: Health().deviceId, sourceId: sourceId, sourceName: sourceName, isManualEntry: isManualEntry, @@ -147,8 +150,8 @@ class HealthDataPoint { dateFrom: $dateFrom, dateTo: $dateTo, dataType: ${type.name}, - platform: $platform, - deviceId: $deviceId, + platform: $sourcePlatform, + deviceId: $sourceDeviceId, sourceId: $sourceId, sourceName: $sourceName isManualEntry: $isManualEntry @@ -162,13 +165,13 @@ class HealthDataPoint { dateFrom == other.dateFrom && dateTo == other.dateTo && type == other.type && - platform == other.platform && - deviceId == other.deviceId && + sourcePlatform == other.sourcePlatform && + sourceDeviceId == other.sourceDeviceId && sourceId == other.sourceId && sourceName == other.sourceName && isManualEntry == other.isManualEntry; @override - int get hashCode => Object.hash(value, unit, dateFrom, dateTo, type, platform, - deviceId, sourceId, sourceName); + int get hashCode => Object.hash(value, unit, dateFrom, dateTo, type, + sourcePlatform, sourceDeviceId, sourceId, sourceName); } diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 317e9db4e..cb755553c 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -37,8 +37,11 @@ class Health { factory Health() => _instance; /// The type of platform of this device. - PlatformType get platformType => - Platform.isAndroid ? PlatformType.ANDROID : PlatformType.IOS; + HealthPlatformType get platformType => Platform.isIOS + ? HealthPlatformType.appleHealth + : useHealthConnectIfAvailable + ? HealthPlatformType.googleHealthConnect + : HealthPlatformType.googleFit; /// The id of this device. /// @@ -51,7 +54,7 @@ class Health { /// If [useHealthConnectIfAvailable] is true, Google Health Connect on /// Android will be used. Has no effect on iOS. Future configure({bool useHealthConnectIfAvailable = false}) async { - _deviceId ??= platformType == PlatformType.ANDROID + _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id : (await _deviceInfo.iosInfo).identifierForVendor; @@ -67,10 +70,9 @@ class Health { 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 health data [types] have been granted with the specified /// access rights [permissions]. @@ -112,7 +114,7 @@ class Health { : 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(), @@ -127,7 +129,7 @@ class Health { /// 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.'); } @@ -146,7 +148,7 @@ class Health { /// Android only. Future getHealthConnectSdkStatus() async { try { - if (platformType == PlatformType.IOS) { + if (Platform.isIOS) { throw UnsupportedError('Health Connect is not available on iOS.'); } final int status = @@ -164,7 +166,7 @@ class Health { /// Android only. Future installHealthConnect() async { try { - if (platformType != PlatformType.ANDROID) { + if (!Platform.isAndroid) { throw UnsupportedError( 'installHealthConnect is only available on Android'); } @@ -193,7 +195,7 @@ class Health { : 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((dataType) => dataType.name).toList(); @@ -253,7 +255,7 @@ class Health { : 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(); final bool? isAuthorized = await _channel.invokeMethod( @@ -265,7 +267,7 @@ class Health { 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]); @@ -312,8 +314,8 @@ class Health { unit: unit, dateFrom: weights[i].dateFrom, dateTo: weights[i].dateTo, - platform: platformType, - deviceId: _deviceId!, + sourcePlatform: platformType, + sourceDeviceId: _deviceId!, sourceId: '', sourceName: '', isManualEntry: !includeManualEntry, @@ -362,7 +364,7 @@ class Health { HealthDataType.IRREGULAR_HEART_RATE_EVENT, HealthDataType.ELECTROCARDIOGRAM, }.contains(type) && - platformType == PlatformType.IOS) { + Platform.isIOS) { throw ArgumentError( "$type - iOS does not support writing this data type in HealthKit"); } @@ -487,13 +489,13 @@ class Health { } bool? success; - if (platformType == PlatformType.IOS) { + if (Platform.isIOS) { success = await writeHealthData( value: saturation, type: HealthDataType.BLOOD_OXYGEN, startTime: startTime, endTime: endTime); - } else if (platformType == PlatformType.ANDROID) { + } else if (Platform.isAndroid) { Map args = { 'value': saturation, 'flowRate': flowRate, @@ -590,7 +592,7 @@ class Health { 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"); } @@ -672,7 +674,7 @@ class Health { bool includeManualEntry, ) async { // Ask for device ID only once - _deviceId ??= platformType == PlatformType.ANDROID + _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id : (await _deviceInfo.iosInfo).identifierForVendor; @@ -683,8 +685,7 @@ class Health { } // If BodyMassIndex is requested on Android, calculate this manually - if (dataType == HealthDataType.BODY_MASS_INDEX && - platformType == PlatformType.ANDROID) { + if (dataType == HealthDataType.BODY_MASS_INDEX && Platform.isAndroid) { return _computeAndroidBMI(startTime, endTime, includeManualEntry); } return await _dataQuery(startTime, endTime, dataType, includeManualEntry); @@ -698,7 +699,7 @@ class Health { int interval, bool includeManualEntry) async { // Ask for device ID only once - _deviceId ??= platformType == PlatformType.ANDROID + _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id : (await _deviceInfo.iosInfo).identifierForVendor; @@ -720,7 +721,7 @@ class Health { int activitySegmentDuration, bool includeManualEntry) async { // Ask for device ID only once - _deviceId ??= platformType == PlatformType.ANDROID + _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id : (await _deviceInfo.iosInfo).identifierForVendor; @@ -899,11 +900,10 @@ class Health { 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"); } diff --git a/packages/health/lib/src/heath_data_types.dart b/packages/health/lib/src/heath_data_types.dart index 37ca9b5cd..dfd35efce 100644 --- a/packages/health/lib/src/heath_data_types.dart +++ b/packages/health/lib/src/heath_data_types.dart @@ -239,10 +239,10 @@ const Map dataTypeToUnit = { HealthDataType.TOTAL_CALORIES_BURNED: HealthDataUnit.KILOCALORIE, }; -const PlatformTypeJsonValue = { - PlatformType.IOS: 'ios', - PlatformType.ANDROID: 'android', -}; +// const PlatformTypeJsonValue = { +// PlatformType.IOS: 'ios', +// PlatformType.ANDROID: 'android', +// }; /// List of all [HealthDataUnit]s. enum HealthDataUnit { From b0947d19d067ca987f5dc2a51dc890708e1f7967 Mon Sep 17 00:00:00 2001 From: bardram Date: Wed, 3 Apr 2024 14:03:17 +0200 Subject: [PATCH 24/24] Update main.dart --- packages/health/example/lib/main.dart | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 84e93cf07..2e77ab376 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -131,23 +131,23 @@ class _HealthAppState extends State { // Clear old data points _healthDataList.clear(); - // try { - // fetch health data - List healthData = await Health().getHealthDataFromTypes( - types: types, - startTime: yesterday, - endTime: now, - ); + try { + // fetch health data + 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.' : ''}'); + 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) { - // debugPrint("Exception in getHealthDataFromTypes: $error"); - // } + // save all the new data points (only the first 100) + _healthDataList.addAll( + (healthData.length < 100) ? healthData : healthData.sublist(0, 100)); + } catch (error) { + debugPrint("Exception in getHealthDataFromTypes: $error"); + } // filter out duplicates _healthDataList = Health().removeDuplicates(_healthDataList);