From ce4d444dd15b926817d56361a6dba2861a203744 Mon Sep 17 00:00:00 2001 From: Aamir Farooq Date: Fri, 30 Aug 2024 13:24:11 +0200 Subject: [PATCH] [Health]: Improve support for `RecordingMethod` on Health Connect and `HKWasUserEntered` on iOS (#1023) * Remove Google Fit and imports from Android code * Formatting * Remove Google Fit column from readme * Remove support for Google Fit types not supported by Health Connect * Remove more Google Fit workout types * Remove references to Google Fit, remove `useHealthConnectIfAvailable` * Remove `disconect` method channel * Remove `flowRate` from `writeBloodOxygen` as it is not supported in Health Connect * Remove more unsupported workout types * Add missing import * Remove Google Fit as dependency * Add notice in README * Improve logging for HC permission callback * Update some documentation * Android: Fix `requestAuthorization` not returning a result on success * Remove additional workout types that are not supported * Remove another workout type * Add missing unimplemented method * Include recording method from Android metadata in HealthDataPoint * Support writing data with custom recording method on Android * Improve RecordingMethod enum * Support filtering by recording method when fetching data * Fix `includeManualEntry` for `getTotalStepsInInterval` * Support recording method on iOS * Recording method when writing on iOS (WIP) * Fix filtering manual entries when fetching data on iOS * Rename variable in example app * Update documentation * Improvements to example app * Quick fix * Update documentation * Update iOS docs --- packages/health/README.md | 33 ++- .../cachet/plugins/health/HealthPlugin.kt | 238 +++++++++++++++++- packages/health/example/lib/main.dart | 232 ++++++++++++----- .../ios/Classes/SwiftHealthPlugin.swift | 77 ++++-- packages/health/lib/health.g.dart | 13 +- .../health/lib/src/health_data_point.dart | 17 +- packages/health/lib/src/health_plugin.dart | 114 +++++++-- .../health/lib/src/health_value_types.dart | 40 +++ 8 files changed, 642 insertions(+), 122 deletions(-) diff --git a/packages/health/README.md b/packages/health/README.md index bbf0cd1fe..1e47314a3 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -181,6 +181,11 @@ Below is a simplified flow of how to use the plugin. bool success = await Health().writeHealthData(10, HealthDataType.STEPS, now, now); success = await Health().writeHealthData(3.1, HealthDataType.BLOOD_GLUCOSE, now, now); + // you can also specify the recording method to store in the metadata (default is RecordingMethod.automatic) + // on iOS only `RecordingMethod.automatic` and `RecordingMethod.manual` are supported + // Android additionally supports `RecordingMethod.active` and `RecordingMethod.unknown` + success &= await Health().writeHealthData(10, HealthDataType.STEPS, now, now, recordingMethod: RecordingMethod.manual); + // get the number of steps for today var midnight = DateTime(now.year, now.month, now.day); int? steps = await Health().getTotalStepsInInterval(midnight, now); @@ -201,7 +206,7 @@ HealthPlatformType sourcePlatform; String sourceDeviceId; String sourceId; String sourceName; -bool isManualEntry; +RecordingMethod recordingMethod; WorkoutSummary? workoutSummary; ``` @@ -223,7 +228,7 @@ A `HealthDataPoint` object can be serialized to and from JSON using the `toJson( "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 + "recording_method": 3 "value": { "__type": "NumericHealthValue", "numeric_value": 141.0 @@ -236,7 +241,7 @@ A `HealthDataPoint` object can be serialized to and from JSON using the `toJson( "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 + "recording_method": 2 } ``` @@ -251,6 +256,28 @@ 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})) ``` +### Filtering by recording method +Google Health Connect and Apple HealthKit both provide ways to distinguish samples collected "automatically" and manually entered data by the user. + +- Android provides an enum with 4 variations: https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/metadata/Metadata#summary +- iOS has a boolean value: https://developer.apple.com/documentation/healthkit/hkmetadatakeywasuserentered + +As such, when fetching data you have the option to filter the fetched data by recording method as such: + +```dart +List healthData = await Health().getHealthDataFromTypes( + types: types, + startTime: yesterday, + endTime: now, + recordingMethodsToFilter: [RecordingMethod.manual, RecordingMethod.unknown], +); +``` + +**Note that for this to work, the information needs to have been provided when writing the data to Health Connect or Apple Health**. For example, steps added manually through the Apple Health App will set `HKWasUserEntered` to true (corresponding to `RecordingMethod.manual`), however it seems that adding steps manually to Google Fit does not write the data with the `RecordingMethod.manual` in the metadata, instead it shows up as `RecordingMethod.unknown`. This is an open issue, and as such filtering manual entries when querying step count on Android with `getTotalStepsInInterval(includeManualEntries: false)` does not necessarily filter out manual steps. + +**NOTE**: On iOS, you can only filter by `RecordingMethod.automatic` and `RecordingMethod`.manual` as it is stored `HKMetadataKeyWasUserEntered` is a boolean value in the metadata. + + ### Filtering out duplicates If the same data is requested multiple times and saved in the same array duplicates will occur. 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 c1a29602c..043c49f42 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 @@ -392,17 +392,26 @@ class HealthPlugin(private var channel: MethodChannel? = null) : private fun getTotalStepsInInterval(call: MethodCall, result: Result) { val start = call.argument("startTime")!! val end = call.argument("endTime")!! + val recordingMethodsToFilter = call.argument>("recordingMethodsToFilter")!! + if (recordingMethodsToFilter.isEmpty()) { + getAggregatedStepCount(start, end, result) + } else { + getStepCountFiltered(start, end, recordingMethodsToFilter, result) + } + } + + private fun getAggregatedStepCount(start: Long, end: Long, result: Result) { + val startInstant = Instant.ofEpochMilli(start) + val endInstant = Instant.ofEpochMilli(end) scope.launch { try { - val startInstant = Instant.ofEpochMilli(start) - val endInstant = Instant.ofEpochMilli(end) val response = healthConnectClient.aggregate( AggregateRequest( metrics = setOf( - StepsRecord.COUNT_TOTAL + StepsRecord.COUNT_TOTAL, ), timeRangeFilter = TimeRangeFilter.between( @@ -415,6 +424,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // time range. val stepsInInterval = response[StepsRecord.COUNT_TOTAL] ?: 0L + Log.i( "FLUTTER_HEALTH::SUCCESS", "returning $stepsInInterval steps" @@ -431,6 +441,40 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } + /** get the step records manually and filter out manual entries **/ + private fun getStepCountFiltered(start: Long, end: Long, recordingMethodsToFilter: List, result: Result) { + scope.launch { + try { + val request = + ReadRecordsRequest( + recordType = StepsRecord::class, + timeRangeFilter = + TimeRangeFilter.between( + Instant.ofEpochMilli(start), + Instant.ofEpochMilli(end) + ), + ) + val response = healthConnectClient.readRecords(request) + val filteredRecords = filterRecordsByRecordingMethods( + recordingMethodsToFilter, + response.records + ) + val totalSteps = filteredRecords.sumOf { (it as StepsRecord).count.toInt() } + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "returning $totalSteps steps (excluding manual entries)" + ) + result.success(totalSteps) + } catch (e: Exception) { + Log.e( + "FLUTTER_HEALTH::ERROR", + "Unable to return steps due to the following exception:" + ) + Log.e("FLUTTER_HEALTH::ERROR", Log.getStackTraceString(e)) + result.success(null) + } + } + } private fun getHealthConnectSdkStatus(call: MethodCall, result: Result) { checkAvailability() @@ -442,6 +486,24 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } result.success(healthConnectStatus) } + + /** Filter records by recording methods */ + private fun filterRecordsByRecordingMethods( + recordingMethodsToFilter: List, + records: List + ): List { + if (recordingMethodsToFilter.isEmpty()) { + return records + } + + return records.filter { record -> + Log.i( + "FLUTTER_HEALTH", + "Filtering record with recording method ${record.metadata.recordingMethod}, filtering by $recordingMethodsToFilter. Result: ${recordingMethodsToFilter.contains(record.metadata.recordingMethod)}" + ) + return@filter !recordingMethodsToFilter.contains(record.metadata.recordingMethod) + } + } private fun hasPermissions(call: MethodCall, result: Result) { val args = call.arguments as HashMap<*, *> @@ -611,6 +673,13 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) val healthConnectData = mutableListOf>() + val recordingMethodsToFilter = call.argument>("recordingMethodsToFilter")!! + + Log.i( + "FLUTTER_HEALTH", + "Getting data for $dataType between $startTime and $endTime, filtering by $recordingMethodsToFilter" + ) + scope.launch { try { mapToType[dataType]?.let { classType -> @@ -658,7 +727,12 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // Workout needs distance and total calories burned too if (dataType == WORKOUT) { - for (rec in records) { + var filteredRecords = filterRecordsByRecordingMethods( + recordingMethodsToFilter, + records + ) + + for (rec in filteredRecords) { val record = rec as ExerciseSessionRecord val distanceRequest = healthConnectClient.readRecords( @@ -774,7 +848,12 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } // Filter sleep stages for requested stage } else if (classType == SleepSessionRecord::class) { - for (rec in response.records) { + val filteredRecords = filterRecordsByRecordingMethods( + recordingMethodsToFilter, + response.records + ) + + for (rec in filteredRecords) { if (rec is SleepSessionRecord) { if (dataType == SLEEP_SESSION) { healthConnectData.addAll( @@ -803,7 +882,11 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } } else { - for (rec in records) { + val filteredRecords = filterRecordsByRecordingMethods( + recordingMethodsToFilter, + records + ) + for (rec in filteredRecords) { healthConnectData.addAll( convertRecord(rec, dataType) ) @@ -942,6 +1025,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -963,6 +1048,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -984,6 +1071,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1003,6 +1092,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1024,6 +1115,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1040,6 +1133,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ) } @@ -1060,6 +1155,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1081,6 +1178,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1102,6 +1201,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1129,6 +1230,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1150,6 +1253,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1171,6 +1276,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1192,6 +1299,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1213,6 +1322,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1234,6 +1345,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1255,6 +1368,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1279,6 +1394,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ), ) @@ -1299,6 +1416,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ) ) @@ -1318,6 +1437,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ) ) @@ -1337,6 +1458,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ) ) @@ -1401,6 +1524,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ) ) @@ -1415,6 +1540,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin .packageName, + "recording_method" to + metadata.recordingMethod ) ) // is ExerciseSessionRecord -> return listOf(mapOf("value" to , @@ -1437,6 +1564,13 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val startTime = call.argument("startTime")!! val endTime = call.argument("endTime")!! val value = call.argument("value")!! + val recordingMethod = call.argument("recordingMethod")!! + + Log.i( + "FLUTTER_HEALTH", + "Writing data for $type between $startTime and $endTime, value: $value, recording method: $recordingMethod" + ) + val record = when (type) { BODY_FAT_PERCENTAGE -> @@ -1450,6 +1584,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : value ), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) HEIGHT -> @@ -1463,6 +1600,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : value ), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) WEIGHT -> @@ -1476,6 +1616,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : value ), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) STEPS -> @@ -1491,6 +1634,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : count = value.toLong(), startZoneOffset = null, endZoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) ACTIVE_ENERGY_BURNED -> @@ -1509,6 +1655,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), startZoneOffset = null, endZoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) HEART_RATE -> @@ -1534,6 +1683,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), startZoneOffset = null, endZoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) BODY_TEMPERATURE -> @@ -1547,6 +1699,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : value ), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) BODY_WATER_MASS -> @@ -1560,6 +1715,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : value ), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) BLOOD_OXYGEN -> @@ -1573,6 +1731,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : value ), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) BLOOD_GLUCOSE -> @@ -1586,6 +1747,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : value ), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) HEART_RATE_VARIABILITY_RMSSD -> @@ -1598,6 +1762,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : value, zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) DISTANCE_DELTA -> @@ -1616,6 +1783,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), startZoneOffset = null, endZoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) WATER -> @@ -1634,6 +1804,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), startZoneOffset = null, endZoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) SLEEP_ASLEEP -> @@ -1662,6 +1835,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : .STAGE_TYPE_SLEEPING ) ), + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) SLEEP_LIGHT -> @@ -1690,6 +1866,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : .STAGE_TYPE_LIGHT ) ), + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) SLEEP_DEEP -> @@ -1718,6 +1897,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : .STAGE_TYPE_DEEP ) ), + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) SLEEP_REM -> @@ -1746,6 +1928,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : .STAGE_TYPE_REM ) ), + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) SLEEP_OUT_OF_BED -> @@ -1774,6 +1959,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : .STAGE_TYPE_OUT_OF_BED ) ), + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) SLEEP_AWAKE -> @@ -1802,6 +1990,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : .STAGE_TYPE_AWAKE ) ), + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) SLEEP_SESSION -> @@ -1816,6 +2007,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), startZoneOffset = null, endZoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) RESTING_HEART_RATE -> @@ -1827,6 +2021,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : beatsPerMinute = value.toLong(), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) BASAL_ENERGY_BURNED -> @@ -1840,6 +2037,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : value ), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) FLIGHTS_CLIMBED -> @@ -1855,6 +2055,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : floors = value, startZoneOffset = null, endZoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) RESPIRATORY_RATE -> @@ -1865,6 +2068,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), rate = value, zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) // AGGREGATE_STEP_COUNT -> StepsRecord() TOTAL_CALORIES_BURNED -> @@ -1883,12 +2089,18 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), startZoneOffset = null, endZoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) MENSTRUATION_FLOW -> MenstruationFlowRecord( time = Instant.ofEpochMilli(startTime), flow = value.toInt(), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ) BLOOD_PRESSURE_SYSTOLIC -> @@ -1933,6 +2145,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") + val recordingMethod = call.argument("recordingMethod")!! if (!workoutTypeMap.containsKey(type)) { result.success(false) Log.w( @@ -1955,6 +2168,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : endZoneOffset = null, exerciseType = workoutType, title = title, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ), ) if (totalDistance != null) { @@ -1968,6 +2184,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : Length.meters( totalDistance.toDouble() ), + metadata = Metadata( + recordingMethod = recordingMethod, + ), ), ) } @@ -1983,6 +2202,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : totalEnergyBurned .toDouble() ), + metadata = Metadata( + recordingMethod = recordingMethod, + ), ), ) } @@ -2011,6 +2233,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val systolic = call.argument("systolic")!! val diastolic = call.argument("diastolic")!! val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) + val recordingMethod = call.argument("recordingMethod")!! scope.launch { try { @@ -2027,6 +2250,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : diastolic ), zoneOffset = null, + metadata = Metadata( + recordingMethod = recordingMethod, + ), ), ), ) diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 3912bda41..038b4cf67 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -36,6 +36,7 @@ class _HealthAppState extends State { List _healthDataList = []; AppState _state = AppState.DATA_NOT_FETCHED; int _nofSteps = 0; + List recordingMethodsToFilter = []; // All types available depending on platform (iOS ot Android). List get types => (Platform.isAndroid) @@ -153,11 +154,15 @@ class _HealthAppState extends State { types: types, startTime: yesterday, endTime: now, + recordingMethodsToFilter: recordingMethodsToFilter, ); debugPrint('Total number of data points: ${healthData.length}. ' '${healthData.length > 100 ? 'Only showing the first 100.' : ''}'); + // sort the data points by date + healthData.sort((a, b) => b.dateTo.compareTo(a.dateTo)); + // save all the new data points (only the first 100) _healthDataList.addAll( (healthData.length < 100) ? healthData : healthData.sublist(0, 100)); @@ -194,29 +199,49 @@ class _HealthAppState extends State { value: 1.925, type: HealthDataType.HEIGHT, startTime: earlier, - endTime: now); + endTime: now, + recordingMethod: RecordingMethod.manual); success &= await Health().writeHealthData( - value: 90, type: HealthDataType.WEIGHT, startTime: now); + value: 90, + type: HealthDataType.WEIGHT, + startTime: now, + recordingMethod: RecordingMethod.manual); success &= await Health().writeHealthData( value: 90, type: HealthDataType.HEART_RATE, startTime: earlier, - endTime: now); + endTime: now, + recordingMethod: RecordingMethod.manual); success &= await Health().writeHealthData( value: 90, type: HealthDataType.STEPS, startTime: earlier, - endTime: now); + endTime: now, + recordingMethod: RecordingMethod.manual); success &= await Health().writeHealthData( - value: 200, - type: HealthDataType.ACTIVE_ENERGY_BURNED, - startTime: earlier, - endTime: now); + 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); + if (Platform.isIOS) { + success &= await Health().writeHealthData( + value: 30, + type: HealthDataType.HEART_RATE_VARIABILITY_SDNN, + startTime: earlier, + endTime: now); + } else { + success &= await Health().writeHealthData( + value: 30, + type: HealthDataType.HEART_RATE_VARIABILITY_RMSSD, + startTime: earlier, + endTime: now); + } success &= await Health().writeHealthData( value: 37, type: HealthDataType.BODY_TEMPERATURE, @@ -275,52 +300,52 @@ class _HealthAppState extends State { startTime: now, ); success &= await Health().writeMeal( - mealType: MealType.SNACK, - startTime: earlier, - endTime: now, - caloriesConsumed: 1000, - carbohydrates: 50, - protein: 25, - fatTotal: 50, - name: "Banana", - caffeine: 0.002, - vitaminA: 0.001, - vitaminC: 0.002, - vitaminD: 0.003, - vitaminE: 0.004, - vitaminK: 0.005, - b1Thiamin: 0.006, - b2Riboflavin: 0.007, - b3Niacin: 0.008, - b5PantothenicAcid: 0.009, - b6Pyridoxine: 0.010, - b7Biotin: 0.011, - b9Folate: 0.012, - b12Cobalamin: 0.013, - calcium: 0.015, - copper: 0.016, - iodine: 0.017, - iron: 0.018, - magnesium: 0.019, - manganese: 0.020, - phosphorus: 0.021, - potassium: 0.022, - selenium: 0.023, - sodium: 0.024, - zinc: 0.025, - water: 0.026, - molybdenum: 0.027, - chloride: 0.028, - chromium: 0.029, - cholesterol: 0.030, - fiber: 0.031, - fatMonounsaturated: 0.032, - fatPolyunsaturated: 0.033, - fatUnsaturated: 0.065, - fatTransMonoenoic: 0.65, - fatSaturated: 066, - sugar: 0.067, - ); + mealType: MealType.SNACK, + startTime: earlier, + endTime: now, + caloriesConsumed: 1000, + carbohydrates: 50, + protein: 25, + fatTotal: 50, + name: "Banana", + caffeine: 0.002, + vitaminA: 0.001, + vitaminC: 0.002, + vitaminD: 0.003, + vitaminE: 0.004, + vitaminK: 0.005, + b1Thiamin: 0.006, + b2Riboflavin: 0.007, + b3Niacin: 0.008, + b5PantothenicAcid: 0.009, + b6Pyridoxine: 0.010, + b7Biotin: 0.011, + b9Folate: 0.012, + b12Cobalamin: 0.013, + calcium: 0.015, + copper: 0.016, + iodine: 0.017, + iron: 0.018, + magnesium: 0.019, + manganese: 0.020, + phosphorus: 0.021, + potassium: 0.022, + selenium: 0.023, + sodium: 0.024, + zinc: 0.025, + water: 0.026, + molybdenum: 0.027, + chloride: 0.028, + chromium: 0.029, + cholesterol: 0.030, + fiber: 0.031, + fatMonounsaturated: 0.032, + fatPolyunsaturated: 0.033, + fatUnsaturated: 0.065, + fatTransMonoenoic: 0.65, + fatSaturated: 066, + sugar: 0.067, + recordingMethod: RecordingMethod.manual); // Store an Audiogram - only available on iOS // const frequencies = [125.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0]; @@ -386,7 +411,9 @@ class _HealthAppState extends State { if (stepsPermission) { try { - steps = await Health().getTotalStepsInInterval(midnight, now); + steps = await Health().getTotalStepsInInterval(midnight, now, + includeManualEntry: + !recordingMethodsToFilter.contains(RecordingMethod.manual)); } catch (error) { debugPrint("Exception in getTotalStepsInInterval: $error"); } @@ -408,6 +435,7 @@ class _HealthAppState extends State { setState(() => _state = AppState.PERMISSIONS_REVOKING); bool success = false; + try { await Health().revokePermissions(); success = true; @@ -498,6 +526,8 @@ class _HealthAppState extends State { ], ), Divider(thickness: 3), + if (_state == AppState.DATA_READY) _dataFiltration, + if (_state == AppState.STEPS_READY) _stepsFiltration, Expanded(child: Center(child: _content)) ], ), @@ -506,6 +536,84 @@ class _HealthAppState extends State { ); } + Widget get _dataFiltration => Column( + children: [ + Wrap( + children: [ + for (final method in Platform.isAndroid + ? [ + RecordingMethod.manual, + RecordingMethod.automatic, + RecordingMethod.active, + RecordingMethod.unknown, + ] + : [ + RecordingMethod.automatic, + RecordingMethod.manual, + ]) + SizedBox( + width: 150, + child: CheckboxListTile( + title: Text( + '${method.name[0].toUpperCase()}${method.name.substring(1)} entries'), + value: !recordingMethodsToFilter.contains(method), + onChanged: (value) { + setState(() { + if (value!) { + recordingMethodsToFilter.remove(method); + } else { + recordingMethodsToFilter.add(method); + } + fetchData(); + }); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + ), + ), + // Add other entries here if needed + ], + ), + Divider(thickness: 3), + ], + ); + + Widget get _stepsFiltration => Column( + children: [ + Wrap( + children: [ + for (final method in [ + RecordingMethod.manual, + ]) + SizedBox( + width: 150, + child: CheckboxListTile( + title: Text( + '${method.name[0].toUpperCase()}${method.name.substring(1)} entries'), + value: !recordingMethodsToFilter.contains(method), + onChanged: (value) { + setState(() { + if (value!) { + recordingMethodsToFilter.remove(method); + } else { + recordingMethodsToFilter.add(method); + } + fetchStepData(); + }); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + ), + ), + // Add other entries here if needed + ], + ), + Divider(thickness: 3), + ], + ); + Widget get _permissionsRevoking => Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -538,12 +646,18 @@ class _HealthAppState extends State { Widget get _contentDataReady => ListView.builder( itemCount: _healthDataList.length, itemBuilder: (_, index) { + // filter out manual entires if not wanted + if (recordingMethodsToFilter + .contains(_healthDataList[index].recordingMethod)) { + return Container(); + } + 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}'), + subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'), ); } if (p.value is WorkoutHealthValue) { @@ -552,7 +666,7 @@ class _HealthAppState extends State { "${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}'), + subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'), ); } if (p.value is NutritionHealthValue) { @@ -561,13 +675,13 @@ class _HealthAppState extends State { "${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}'), + subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'), ); } return ListTile( title: Text("${p.typeString}: ${p.value}"), trailing: Text('${p.unitString}'), - subtitle: Text('${p.dateFrom} - ${p.dateTo}'), + subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'), ); }); diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 19d8cc207..654f89991 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -2,6 +2,13 @@ import Flutter import HealthKit import UIKit +enum RecordingMethod: Int { + case unknown = 0 // RECORDING_METHOD_UNKNOWN (not supported on iOS) + case active = 1 // RECORDING_METHOD_ACTIVELY_RECORDED (not supported on iOS) + case automatic = 2 // RECORDING_METHOD_AUTOMATICALLY_RECORDED + case manual = 3 // RECORDING_METHOD_MANUAL_ENTRY +} + public class SwiftHealthPlugin: NSObject, FlutterPlugin { let healthStore = HKHealthStore() @@ -414,25 +421,31 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let type = (arguments["dataTypeKey"] as? String), let unit = (arguments["dataUnitKey"] as? String), let startTime = (arguments["startTime"] as? NSNumber), - let endTime = (arguments["endTime"] as? NSNumber) + let endTime = (arguments["endTime"] as? NSNumber), + let recordingMethod = (arguments["recordingMethod"] as? Int) else { throw PluginError(message: "Invalid Arguments") } let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) + + let isManualEntry = recordingMethod == RecordingMethod.manual.rawValue + let metadata: [String: Any] = [ + HKMetadataKeyWasUserEntered: NSNumber(value: isManualEntry) + ] let sample: HKObject if dataTypeLookUp(key: type).isKind(of: HKCategoryType.self) { sample = HKCategorySample( type: dataTypeLookUp(key: type) as! HKCategoryType, value: Int(value), start: dateFrom, - end: dateTo) + end: dateTo, metadata: metadata) } else { let quantity = HKQuantity(unit: unitDict[unit]!, doubleValue: value) sample = HKQuantitySample( type: dataTypeLookUp(key: type) as! HKQuantityType, quantity: quantity, start: dateFrom, - end: dateTo) + end: dateTo, metadata: metadata) } HKHealthStore().save( @@ -506,21 +519,27 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let systolic = (arguments["systolic"] as? Double), let diastolic = (arguments["diastolic"] as? Double), let startTime = (arguments["startTime"] as? NSNumber), - let endTime = (arguments["endTime"] as? NSNumber) + let endTime = (arguments["endTime"] as? NSNumber), + let recordingMethod = (arguments["recordingMethod"] as? Int) else { throw PluginError(message: "Invalid Arguments") } let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) + + let isManualEntry = recordingMethod == RecordingMethod.manual.rawValue + let metadata = [ + HKMetadataKeyWasUserEntered: NSNumber(value: isManualEntry) + ] let systolic_sample = HKQuantitySample( type: HKSampleType.quantityType(forIdentifier: .bloodPressureSystolic)!, quantity: HKQuantity(unit: HKUnit.millimeterOfMercury(), doubleValue: systolic), - start: dateFrom, end: dateTo) + start: dateFrom, end: dateTo, metadata: metadata) let diastolic_sample = HKQuantitySample( type: HKSampleType.quantityType(forIdentifier: .bloodPressureDiastolic)!, quantity: HKQuantity(unit: HKUnit.millimeterOfMercury(), doubleValue: diastolic), - start: dateFrom, end: dateTo) + start: dateFrom, end: dateTo, metadata: metadata) 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) @@ -542,7 +561,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let name = (arguments["name"] as? String?), let startTime = (arguments["start_time"] as? NSNumber), let endTime = (arguments["end_time"] as? NSNumber), - let mealType = (arguments["meal_type"] as? String?) + let mealType = (arguments["meal_type"] as? String?), + let recordingMethod = arguments["recordingMethod"] as? Int else { throw PluginError(message: "Invalid Arguments") } @@ -551,12 +571,14 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let dateTo = Date(timeIntervalSince1970: endTime.doubleValue / 1000) let mealTypeString = mealType ?? "UNKNOWN" - var metadata = ["HKFoodMeal": "\(mealTypeString)"] + + let isManualEntry = recordingMethod == RecordingMethod.manual.rawValue - if(name != nil) { + var metadata = ["HKFoodMeal": mealTypeString, HKMetadataKeyWasUserEntered: NSNumber(value: isManualEntry)] as [String : Any] + if (name != nil) { metadata[HKMetadataKeyFoodType] = "\(name!)" } - + var nutrition = Set() for (key, identifier) in NUTRITION_KEYS { let value = arguments[key] as? Double @@ -615,7 +637,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { guard let arguments = call.arguments as? NSDictionary, let flow = (arguments["value"] as? Int), let endTime = (arguments["endTime"] as? NSNumber), - let isStartOfCycle = (arguments["isStartOfCycle"] as? NSNumber) + let isStartOfCycle = (arguments["isStartOfCycle"] as? NSNumber), + let recordingMethod = (arguments["recordingMethod"] as? Int) else { throw PluginError(message: "Invalid Arguments - value, startTime, endTime or isStartOfCycle invalid") } @@ -625,11 +648,13 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let dateTime = Date(timeIntervalSince1970: endTime.doubleValue / 1000) + let isManualEntry = recordingMethod == RecordingMethod.manual.rawValue + guard let categoryType = HKSampleType.categoryType(forIdentifier: .menstrualFlow) else { throw PluginError(message: "Invalid Menstrual Flow Type") } - let metadata = [HKMetadataKeyMenstrualCycleStart: isStartOfCycle] + let metadata = [HKMetadataKeyMenstrualCycleStart: isStartOfCycle, HKMetadataKeyWasUserEntered: NSNumber(value: isManualEntry)] as [String : Any] let sample = HKCategorySample( type: categoryType, @@ -743,7 +768,8 @@ 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 + let recordingMethodsToFilter = (arguments?["recordingMethodsToFilter"] as? [Int]) ?? [] + let includeManualEntry = !recordingMethodsToFilter.contains(RecordingMethod.manual.rawValue) // Convert dates from milliseconds to Date() let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) @@ -768,7 +794,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { "date_to": Int(dateTo.timeIntervalSince1970 * 1000), "source_id": sourceIdForCharacteristic, "source_name": sourceNameForCharacteristic, - "is_manual_entry": true + "recording_method": RecordingMethod.manual.rawValue ] ]) return @@ -781,7 +807,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { "date_to": Int(dateTo.timeIntervalSince1970 * 1000), "source_id": sourceIdForCharacteristic, "source_name": sourceNameForCharacteristic, - "is_manual_entry": true + "recording_method": RecordingMethod.manual.rawValue ] ]) return @@ -794,7 +820,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { "date_to": Int(dateTo.timeIntervalSince1970 * 1000), "source_id": sourceIdForCharacteristic, "source_name": sourceNameForCharacteristic, - "is_manual_entry": true + "recording_method": RecordingMethod.manual.rawValue ] ]) return @@ -826,7 +852,9 @@ 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, + "recording_method": (sample.metadata?[HKMetadataKeyWasUserEntered] as? Bool == true) + ? RecordingMethod.manual.rawValue + : RecordingMethod.automatic.rawValue, "metadata": dataTypeKey == INSULIN_DELIVERY ? sample.metadata : nil ] } @@ -891,7 +919,7 @@ 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, + "recording_method": (sample.metadata?[HKMetadataKeyWasUserEntered] as? Bool == true) ? RecordingMethod.manual.rawValue : RecordingMethod.automatic.rawValue, "metadata": metadata ] } @@ -915,7 +943,7 @@ 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, + "recording_method": (sample.metadata?[HKMetadataKeyWasUserEntered] as? Bool == true) ? RecordingMethod.manual.rawValue : RecordingMethod.automatic.rawValue, "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 @@ -969,6 +997,9 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { "date_to": Int(sample.endDate.timeIntervalSince1970 * 1000), "source_id": sample.sourceRevision.source.bundleIdentifier, "source_name": sample.sourceRevision.source.name, + "recording_method": (sample.metadata?[HKMetadataKeyWasUserEntered] as? Bool == true) + ? RecordingMethod.manual.rawValue + : RecordingMethod.automatic.rawValue ] for sample in samples { if let quantitySample = sample as? HKQuantitySample { @@ -1047,7 +1078,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { 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 + let recordingMethodsToFilter = (arguments?["recordingMethodsToFilter"] as? [Int]) ?? [] + let includeManualEntry = !recordingMethodsToFilter.contains(RecordingMethod.manual.rawValue) // Set interval in seconds. var interval = DateComponents() @@ -1131,7 +1163,8 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let arguments = call.arguments as? NSDictionary let startTime = (arguments?["startTime"] as? NSNumber) ?? 0 let endTime = (arguments?["endTime"] as? NSNumber) ?? 0 - let includeManualEntry = (arguments?["includeManualEntry"] as? Bool) ?? true + let recordingMethodsToFilter = (arguments?["recordingMethodsToFilter"] as? [Int]) ?? [] + let includeManualEntry = !recordingMethodsToFilter.contains(RecordingMethod.manual.rawValue) // Convert dates from milliseconds to Date() let dateFrom = Date(timeIntervalSince1970: startTime.doubleValue / 1000) @@ -1743,5 +1776,5 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { default: return "other" } - } + } } diff --git a/packages/health/lib/health.g.dart b/packages/health/lib/health.g.dart index 13f14c5a6..565741982 100644 --- a/packages/health/lib/health.g.dart +++ b/packages/health/lib/health.g.dart @@ -19,7 +19,9 @@ HealthDataPoint _$HealthDataPointFromJson(Map json) => 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, + recordingMethod: $enumDecodeNullable( + _$RecordingMethodEnumMap, json['recording_method']) ?? + RecordingMethod.unknown, workoutSummary: json['workout_summary'] == null ? null : WorkoutSummary.fromJson( @@ -39,7 +41,7 @@ Map _$HealthDataPointToJson(HealthDataPoint instance) { 'source_device_id': instance.sourceDeviceId, 'source_id': instance.sourceId, 'source_name': instance.sourceName, - 'is_manual_entry': instance.isManualEntry, + 'recording_method': _$RecordingMethodEnumMap[instance.recordingMethod]!, }; void writeNotNull(String key, dynamic value) { @@ -211,6 +213,13 @@ const _$HealthPlatformTypeEnumMap = { HealthPlatformType.googleHealthConnect: 'googleHealthConnect', }; +const _$RecordingMethodEnumMap = { + RecordingMethod.unknown: 'unknown', + RecordingMethod.active: 'active', + RecordingMethod.automatic: 'automatic', + RecordingMethod.manual: 'manual', +}; + HealthValue _$HealthValueFromJson(Map json) => HealthValue()..$type = json['__type'] as String?; diff --git a/packages/health/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index ec5a98a9c..adb597061 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -44,8 +44,10 @@ class HealthDataPoint { /// The name of the source from which the data point was fetched. String sourceName; - /// The user entered state of the data point. - bool isManualEntry; + /// How the data point was recorded + /// (on Android: https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/metadata/Metadata#summary) + /// on iOS: either user entered or manual https://developer.apple.com/documentation/healthkit/hkmetadatakeywasuserentered) + RecordingMethod recordingMethod; /// The summary of the workout data point, if available. WorkoutSummary? workoutSummary; @@ -64,7 +66,7 @@ class HealthDataPoint { required this.sourceDeviceId, required this.sourceId, required this.sourceName, - this.isManualEntry = false, + this.recordingMethod = RecordingMethod.unknown, this.workoutSummary, this.metadata, }) { @@ -128,7 +130,6 @@ class HealthDataPoint { DateTime.fromMillisecondsSinceEpoch(dataPoint['date_to'] as int); final String sourceId = dataPoint["source_id"] as String; final String sourceName = dataPoint["source_name"] as String; - final bool isManualEntry = dataPoint["is_manual_entry"] as bool? ?? false; final Map? metadata = dataPoint["metadata"] == null ? null : Map.from(dataPoint['metadata'] as Map); @@ -144,6 +145,8 @@ class HealthDataPoint { workoutSummary = WorkoutSummary.fromHealthDataPoint(dataPoint); } + var recordingMethod = dataPoint["recording_method"] as int?; + return HealthDataPoint( uuid: uuid ?? "", value: value, @@ -155,7 +158,7 @@ class HealthDataPoint { sourceDeviceId: Health().deviceId, sourceId: sourceId, sourceName: sourceName, - isManualEntry: isManualEntry, + recordingMethod: RecordingMethod.fromInt(recordingMethod), workoutSummary: workoutSummary, metadata: metadata, ); @@ -173,7 +176,7 @@ class HealthDataPoint { deviceId: $sourceDeviceId, sourceId: $sourceId, sourceName: $sourceName - isManualEntry: $isManualEntry + recordingMethod: $recordingMethod workoutSummary: $workoutSummary metadata: $metadata"""; @@ -190,7 +193,7 @@ class HealthDataPoint { sourceDeviceId == other.sourceDeviceId && sourceId == other.sourceId && sourceName == other.sourceName && - isManualEntry == other.isManualEntry && + recordingMethod == other.recordingMethod && metadata == other.metadata; @override diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 9375e5c81..368dd6399 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -241,17 +241,17 @@ class Health { Future> _computeAndroidBMI( DateTime startTime, DateTime endTime, - bool includeManualEntry, + List recordingMethodsToFilter, ) async { List heights = await _prepareQuery( - startTime, endTime, HealthDataType.HEIGHT, includeManualEntry); + startTime, endTime, HealthDataType.HEIGHT, recordingMethodsToFilter); if (heights.isEmpty) { return []; } List weights = await _prepareQuery( - startTime, endTime, HealthDataType.WEIGHT, includeManualEntry); + startTime, endTime, HealthDataType.WEIGHT, recordingMethodsToFilter); double h = (heights.last.value as NumericHealthValue).numericValue.toDouble(); @@ -275,7 +275,7 @@ class Health { sourceDeviceId: _deviceId!, sourceId: '', sourceName: '', - isManualEntry: !includeManualEntry, + recordingMethod: RecordingMethod.unknown, ); bmiHealthPoints.add(x); @@ -297,6 +297,8 @@ class Health { /// 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). + /// * [recordingMethod] - the recording method of the data point, automatic by default. + /// (on iOS this must be manual or automatic) /// /// Values for Sleep and Headache are ignored and will be automatically assigned /// the default value. @@ -306,7 +308,14 @@ class Health { required HealthDataType type, required DateTime startTime, DateTime? endTime, + RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { + if (Platform.isIOS && + [RecordingMethod.active, RecordingMethod.unknown] + .contains(recordingMethod)) { + throw ArgumentError("recordingMethod must be manual or automatic on iOS"); + } + if (type == HealthDataType.WORKOUT) { throw ArgumentError( "Adding workouts should be done using the writeWorkoutData method."); @@ -356,7 +365,8 @@ class Health { 'dataTypeKey': type.name, 'dataUnitKey': unit.name, 'startTime': startTime.millisecondsSinceEpoch, - 'endTime': endTime.millisecondsSinceEpoch + 'endTime': endTime.millisecondsSinceEpoch, + 'recordingMethod': recordingMethod.toInt(), }; bool? success = await _channel.invokeMethod('writeData', args); return success ?? false; @@ -404,12 +414,20 @@ class Health { /// Must be equal to or later than [startTime]. /// Simply set [endTime] equal to [startTime] if the blood pressure is measured /// only at a specific point in time. If omitted, [endTime] is set to [startTime]. + /// * [recordingMethod] - the recording method of the data point. Future writeBloodPressure({ required int systolic, required int diastolic, required DateTime startTime, DateTime? endTime, + RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { + if (Platform.isIOS && + [RecordingMethod.active, RecordingMethod.unknown] + .contains(recordingMethod)) { + throw ArgumentError("recordingMethod must be manual or automatic on iOS"); + } + endTime ??= startTime; if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); @@ -419,7 +437,8 @@ class Health { 'systolic': systolic, 'diastolic': diastolic, 'startTime': startTime.millisecondsSinceEpoch, - 'endTime': endTime.millisecondsSinceEpoch + 'endTime': endTime.millisecondsSinceEpoch, + 'recordingMethod': recordingMethod.toInt(), }; return await _channel.invokeMethod('writeBloodPressure', args) == true; } @@ -436,11 +455,19 @@ class Health { /// Must be equal to or later than [startTime]. /// Simply set [endTime] equal to [startTime] if the blood oxygen saturation /// is measured only at a specific point in time (default). + /// * [recordingMethod] - the recording method of the data point. Future writeBloodOxygen({ required double saturation, required DateTime startTime, DateTime? endTime, + RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { + if (Platform.isIOS && + [RecordingMethod.active, RecordingMethod.unknown] + .contains(recordingMethod)) { + throw ArgumentError("recordingMethod must be manual or automatic on iOS"); + } + endTime ??= startTime; if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); @@ -452,13 +479,15 @@ class Health { value: saturation, type: HealthDataType.BLOOD_OXYGEN, startTime: startTime, - endTime: endTime); + endTime: endTime, + recordingMethod: recordingMethod); } else if (Platform.isAndroid) { Map args = { 'value': saturation, 'startTime': startTime.millisecondsSinceEpoch, 'endTime': endTime.millisecondsSinceEpoch, 'dataTypeKey': HealthDataType.BLOOD_OXYGEN.name, + 'recordingMethod': recordingMethod.toInt(), }; success = await _channel.invokeMethod('writeBloodOxygen', args); } @@ -517,6 +546,7 @@ class Health { /// * [sugar] - optional sugar information. /// * [water] - optional water information. /// * [zinc] - optional zinc information. + /// * [recordingMethod] - the recording method of the data point. Future writeMeal({ required MealType mealType, required DateTime startTime, @@ -563,7 +593,14 @@ class Health { double? sugar, double? water, double? zinc, + RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { + if (Platform.isIOS && + [RecordingMethod.active, RecordingMethod.unknown] + .contains(recordingMethod)) { + throw ArgumentError("recordingMethod must be manual or automatic on iOS"); + } + if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); } @@ -614,6 +651,7 @@ class Health { 'sugar': sugar, 'water': water, 'zinc': zinc, + 'recordingMethod': recordingMethod.toInt(), }; bool? success = await _channel.invokeMethod('writeMeal', args); return success ?? false; @@ -629,12 +667,20 @@ class Health { /// * [endTime] - the start time when the menstrual flow is measured. /// * [isStartOfCycle] - A bool that indicates whether the sample represents /// the start of a menstrual cycle. + /// * [recordingMethod] - the recording method of the data point. Future writeMenstruationFlow({ required MenstrualFlow flow, required DateTime startTime, required DateTime endTime, required bool isStartOfCycle, + RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { + if (Platform.isIOS && + [RecordingMethod.active, RecordingMethod.unknown] + .contains(recordingMethod)) { + throw ArgumentError("recordingMethod must be manual or automatic on iOS"); + } + var value = Platform.isAndroid ? MenstrualFlow.toHealthConnect(flow) : flow.index; @@ -649,6 +695,7 @@ class Health { 'endTime': endTime.millisecondsSinceEpoch, 'isStartOfCycle': isStartOfCycle, 'dataTypeKey': HealthDataType.MENSTRUATION_FLOW.name, + 'recordingMethod': recordingMethod.toInt(), }; return await _channel.invokeMethod('writeMenstruationFlow', args) == true; } @@ -750,17 +797,19 @@ class Health { } /// Fetch a list of health data points based on [types]. + /// You can also specify the [recordingMethodsToFilter] to filter the data points. + /// If not specified, all data points will be included. Future> getHealthDataFromTypes({ required List types, required DateTime startTime, required DateTime endTime, - bool includeManualEntry = true, + List recordingMethodsToFilter = const [], }) async { List dataPoints = []; for (var type in types) { - final result = - await _prepareQuery(startTime, endTime, type, includeManualEntry); + final result = await _prepareQuery( + startTime, endTime, type, recordingMethodsToFilter); dataPoints.addAll(result); } @@ -773,17 +822,19 @@ class Health { } /// Fetch a list of health data points based on [types]. + /// You can also specify the [recordingMethodsToFilter] to filter the data points. + /// If not specified, all data points will be included.Vkk Future> getHealthIntervalDataFromTypes( {required DateTime startDate, required DateTime endDate, required List types, required int interval, - bool includeManualEntry = true}) async { + List recordingMethodsToFilter = const []}) async { List dataPoints = []; for (var type in types) { final result = await _prepareIntervalQuery( - startDate, endDate, type, interval, includeManualEntry); + startDate, endDate, type, interval, recordingMethodsToFilter); dataPoints.addAll(result); } @@ -812,7 +863,7 @@ class Health { DateTime startTime, DateTime endTime, HealthDataType dataType, - bool includeManualEntry, + List recordingMethodsToFilter, ) async { // Ask for device ID only once _deviceId ??= Platform.isAndroid @@ -827,9 +878,10 @@ class Health { // If BodyMassIndex is requested on Android, calculate this manually if (dataType == HealthDataType.BODY_MASS_INDEX && Platform.isAndroid) { - return _computeAndroidBMI(startTime, endTime, includeManualEntry); + return _computeAndroidBMI(startTime, endTime, recordingMethodsToFilter); } - return await _dataQuery(startTime, endTime, dataType, includeManualEntry); + return await _dataQuery( + startTime, endTime, dataType, recordingMethodsToFilter); } /// Prepares an interval query, i.e. checks if the types are available, etc. @@ -838,7 +890,7 @@ class Health { DateTime endDate, HealthDataType dataType, int interval, - bool includeManualEntry) async { + List recordingMethodsToFilter) async { // Ask for device ID only once _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id @@ -851,7 +903,7 @@ class Health { } return await _dataIntervalQuery( - startDate, endDate, dataType, interval, includeManualEntry); + startDate, endDate, dataType, interval, recordingMethodsToFilter); } /// Prepares an aggregate query, i.e. checks if the types are available, etc. @@ -878,14 +930,18 @@ class Health { } /// Fetches data points from Android/iOS native code. - Future> _dataQuery(DateTime startTime, DateTime endTime, - HealthDataType dataType, bool includeManualEntry) async { + Future> _dataQuery( + DateTime startTime, + DateTime endTime, + HealthDataType dataType, + List recordingMethodsToFilter) async { final args = { 'dataTypeKey': dataType.name, 'dataUnitKey': dataTypeToUnit[dataType]!.name, 'startTime': startTime.millisecondsSinceEpoch, 'endTime': endTime.millisecondsSinceEpoch, - 'includeManualEntry': includeManualEntry + 'recordingMethodsToFilter': + recordingMethodsToFilter.map((e) => e.toInt()).toList(), }; final fetchedDataPoints = await _channel.invokeMethod('getData', args); @@ -912,14 +968,15 @@ class Health { DateTime endDate, HealthDataType dataType, int interval, - bool includeManualEntry) async { + List recordingMethodsToFilter) async { final args = { 'dataTypeKey': dataType.name, 'dataUnitKey': dataTypeToUnit[dataType]!.name, 'startTime': startDate.millisecondsSinceEpoch, 'endTime': endDate.millisecondsSinceEpoch, 'interval': interval, - 'includeManualEntry': includeManualEntry + 'recordingMethodsToFilter': + recordingMethodsToFilter.map((e) => e.toInt()).toList(), }; final fetchedDataPoints = @@ -983,7 +1040,9 @@ class Health { final args = { 'startTime': startTime.millisecondsSinceEpoch, 'endTime': endTime.millisecondsSinceEpoch, - 'includeManualEntry': includeManualEntry + 'recordingMethodsToFilter': includeManualEntry + ? [] + : [RecordingMethod.manual.toInt()], }; final stepsCount = await _channel.invokeMethod( 'getTotalStepsInInterval', @@ -1027,6 +1086,7 @@ class Health { /// *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". + /// - [recordingMethod] The recording method of the data point, automatic by default (on iOS this can only be automatic or manual). Future writeWorkoutData({ required HealthWorkoutActivityType activityType, required DateTime start, @@ -1036,7 +1096,14 @@ class Health { int? totalDistance, HealthDataUnit totalDistanceUnit = HealthDataUnit.METER, String? title, + RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { + if (Platform.isIOS && + [RecordingMethod.active, RecordingMethod.unknown] + .contains(recordingMethod)) { + throw ArgumentError("recordingMethod must be manual or automatic on iOS"); + } + // Check that value is on the current Platform if (Platform.isIOS && !_isOnIOS(activityType)) { throw HealthException(activityType, @@ -1054,6 +1121,7 @@ class Health { 'totalDistance': totalDistance, 'totalDistanceUnit': totalDistanceUnit.name, 'title': title, + 'recordingMethod': recordingMethod.toInt(), }; return await _channel.invokeMethod('writeWorkoutData', args) == true; } diff --git a/packages/health/lib/src/health_value_types.dart b/packages/health/lib/src/health_value_types.dart index 502328b51..d313104ca 100644 --- a/packages/health/lib/src/health_value_types.dart +++ b/packages/health/lib/src/health_value_types.dart @@ -818,6 +818,46 @@ enum MenstrualFlow { } } +enum RecordingMethod { + unknown, + active, + automatic, + manual; + + /// Create a [RecordingMethod] from an integer. + /// 0: unknown, 1: active, 2: automatic, 3: manual + /// If the integer is not in the range of 0-3, [RecordingMethod.unknown] is returned. + /// This is used to align the recording method with the platform. + static RecordingMethod fromInt(int? recordingMethod) { + switch (recordingMethod) { + case 0: + return RecordingMethod.unknown; + case 1: + return RecordingMethod.active; + case 2: + return RecordingMethod.automatic; + case 3: + return RecordingMethod.manual; + default: + return RecordingMethod.unknown; + } + } + + /// Convert this [RecordingMethod] to an integer. + int toInt() { + switch (this) { + case RecordingMethod.unknown: + return 0; + case RecordingMethod.active: + return 1; + case RecordingMethod.automatic: + return 2; + case RecordingMethod.manual: + return 3; + } + } +} + /// A [HealthValue] object for menstrual flow. /// /// Parameters: