diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 153d069b9..44de9d899 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,3 +1,14 @@ +## 13.2.0 + +* Add get health data by UUID (see `getHealthDataByUUID()`) - PR [#1193](https://github.com/carp-dk/flutter-plugins/pull/1193), [#1194](https://github.com/carp-dk/flutter-plugins/pull/1194) +* Add delete by UUID (`deleteByUUID()`) +* Add support for unit conversion in `WeightRecord`, `HeightRecord`, `BodyTemperatureRecord`, and `BloodGlucoseRecord` - PR [#1212](https://github.com/carp-dk/flutter-plugins/pull/1223) +* Update `compileSDK` to 36 - Fix [#1261](https://github.com/carp-dk/flutter-plugins/issues/1261) +* Update Gradle to 8.9.1 +* Update `org.jetbrains.kotlin.android` to 2.1.0 +* Update `androidx.health.connect:connect-client` to 1.1.0-rc03 +* Update `device_info_plus` to 12.1.0 - Fix [#1264](https://github.com/carp-dk/flutter-plugins/issues/1264) + ## 13.1.4 * Fix adding mindfulness resulted in crash in iOS diff --git a/packages/health/README.md b/packages/health/README.md index 17d8c71d7..f090b744e 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -270,6 +270,22 @@ 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})) ``` +### Fetch single health data by UUID + +In order to retrieve a single record, it is required to provide `String uuid` and `HealthDataType type`. + +Please see example below: +```dart +HealthDataPoint? healthPoint = await health.getHealthDataByUUID( + uuid: 'random-uuid-string', + type: HealthDataType.STEPS, +); +``` +``` +I/FLUTTER_HEALTH( 9161): Success: {uuid=random-uuid-string, value=12, date_from=1742259061009, date_to=1742259092888, source_id=, source_name=com.google.android.apps.fitness, recording_method=0} +``` +> Assuming that the `uuid` and `type` are coming from your database. + ### 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. @@ -322,6 +338,43 @@ Furthermore, the plugin now exposes three new functions to help you check and re 2. `isHealthDataInBackgroundAuthorized()`: Checks the current status of the Health Data in Background permission 3. `requestHealthDataInBackgroundAuthorization()`: Requests the Health Data in Background permission. +### Fetch single health data by UUID + +In order to retrieve a single record, it is required to provide `String uuid` and `HealthDataType type`. + +Please see example below: +```dart +HealthDataPoint? healthPoint = await health.getHealthDataByUUID( + uuid: 'E9F2EEAD-8FC5-4CE5-9FF5-7C4E535FB8B8', + type: HealthDataType.WORKOUT, +); +``` +``` +data by UUID: HealthDataPoint - + uuid: E9F2EEAD-8FC5-4CE5-9FF5-7C4E535FB8B8, + value: WorkoutHealthValue - workoutActivityType: RUNNING, + totalEnergyBurned: null, + totalEnergyBurnedUnit: KILOCALORIE, + totalDistance: 2400, + totalDistanceUnit: METER + totalSteps: null, + totalStepsUnit: null, + unit: NO_UNIT, + dateFrom: 2025-05-02 07:31:00.000, + dateTo: 2025-05-02 08:25:00.000, + dataType: WORKOUT, + platform: HealthPlatformType.appleHealth, + deviceId: unknown, + sourceId: com.apple.Health, + sourceName: Health + recordingMethod: RecordingMethod.manual + workoutSummary: WorkoutSummary - workoutType: runningtotalDistance: 2400, totalEnergyBurned: 0, totalSteps: 0 + metadata: null + deviceModel: null +``` +> Assuming that the `uuid` and `type` are coming from your database. + + ## Data Types The plugin supports the following [`HealthDataType`](https://pub.dev/documentation/health/latest/health/HealthDataType.html). diff --git a/packages/health/android/build.gradle b/packages/health/android/build.gradle index 45c7b67d8..b4194b9dc 100644 --- a/packages/health/android/build.gradle +++ b/packages/health/android/build.gradle @@ -9,7 +9,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.1.4' + classpath 'com.android.tools.build:gradle:8.13.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -25,7 +25,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { - compileSdk 34 + compileSdk 36 compileOptions { sourceCompatibility JavaVersion.VERSION_11 @@ -41,7 +41,7 @@ android { } defaultConfig { minSdkVersion 26 - targetSdkVersion 34 + targetSdkVersion 36 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } lintOptions { @@ -51,12 +51,11 @@ android { } dependencies { - def composeBom = platform('androidx.compose:compose-bom:2025.02.00') + def composeBom = platform('androidx.compose:compose-bom:2025.09.00') implementation(composeBom) - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10" - implementation("androidx.health.connect:connect-client:1.1.0-alpha11") - def fragment_version = "1.8.6" - implementation "androidx.fragment:fragment-ktx:$fragment_version" + implementation("androidx.health.connect:connect-client:1.1.0-rc03") + implementation "androidx.fragment:fragment-ktx:1.8.9" } diff --git a/packages/health/android/gradle/wrapper/gradle-wrapper.properties b/packages/health/android/gradle/wrapper/gradle-wrapper.properties index a59520664..cb3cdef9c 100644 --- a/packages/health/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/health/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Tue Sep 16 09:25:00 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataConverter.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataConverter.kt index 7cbd7f951..6d288170b 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataConverter.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataConverter.kt @@ -21,20 +21,34 @@ class HealthDataConverter { * @return List> List of converted records (some records may split into multiple entries) * @throws IllegalArgumentException If the record type is not supported */ - fun convertRecord(record: Any, dataType: String): List> { + fun convertRecord(record: Any, dataType: String, dataUnit: String? = null): List> { val metadata = (record as Record).metadata return when (record) { // Single-value instant records - is WeightRecord -> listOf(createInstantRecord(metadata, record.time, record.weight.inKilograms)) - is HeightRecord -> listOf(createInstantRecord(metadata, record.time, record.height.inMeters)) + is WeightRecord -> listOf(createInstantRecord(metadata, record.time, when (dataUnit) { + "POUND" -> record.weight.inPounds + else -> record.weight.inKilograms + })) + is HeightRecord -> listOf(createInstantRecord(metadata, record.time, when (dataUnit) { + "CENTIMETER" -> (record.height.inMeters * 100) + "INCH" -> record.height.inInches + else -> record.height.inMeters + })) is BodyFatRecord -> listOf(createInstantRecord(metadata, record.time, record.percentage.value)) is LeanBodyMassRecord -> listOf(createInstantRecord(metadata, record.time, record.mass.inKilograms)) is HeartRateVariabilityRmssdRecord -> listOf(createInstantRecord(metadata, record.time, record.heartRateVariabilityMillis)) - is BodyTemperatureRecord -> listOf(createInstantRecord(metadata, record.time, record.temperature.inCelsius)) + is BodyTemperatureRecord -> listOf(createInstantRecord(metadata, record.time, when (dataUnit) { + "DEGREE_FAHRENHEIT" -> record.temperature.inFahrenheit + "KELVIN" -> record.temperature.inCelsius + 273.15 + else -> record.temperature.inCelsius + })) is BodyWaterMassRecord -> listOf(createInstantRecord(metadata, record.time, record.mass.inKilograms)) is OxygenSaturationRecord -> listOf(createInstantRecord(metadata, record.time, record.percentage.value)) - is BloodGlucoseRecord -> listOf(createInstantRecord(metadata, record.time, record.level.inMilligramsPerDeciliter)) + is BloodGlucoseRecord -> listOf(createInstantRecord(metadata, record.time, when (dataUnit) { + "MILLIMOLES_PER_LITER" -> record.level.inMillimolesPerLiter + else -> record.level.inMilligramsPerDeciliter + })) is BasalMetabolicRateRecord -> listOf(createInstantRecord(metadata, record.time, record.basalMetabolicRate.inKilocaloriesPerDay)) is RestingHeartRateRecord -> listOf(createInstantRecord(metadata, record.time, record.beatsPerMinute)) is RespiratoryRateRecord -> listOf(createInstantRecord(metadata, record.time, record.rate)) @@ -236,7 +250,7 @@ class HealthDataConverter { ) ) } - + companion object { private const val BLOOD_PRESSURE_DIASTOLIC = "BLOOD_PRESSURE_DIASTOLIC" private const val MEAL_UNKNOWN = "UNKNOWN" diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt index e04b2cb8a..78561516b 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt @@ -3,7 +3,6 @@ package cachet.plugins.health import android.util.Log import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.HealthConnectFeatures -import androidx.health.connect.client.feature.ExperimentalFeatureAvailabilityApi import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_READ_HEALTH_DATA_HISTORY import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND @@ -103,7 +102,6 @@ class HealthDataOperations( * @param call Method call from Flutter (unused) * @param result Flutter result callback returning boolean availability status */ - @OptIn(ExperimentalFeatureAvailabilityApi::class) fun isHealthDataHistoryAvailable(call: MethodCall, result: Result) { scope.launch { result.success( @@ -139,7 +137,6 @@ class HealthDataOperations( * @param call Method call from Flutter (unused) * @param result Flutter result callback returning boolean availability status */ - @OptIn(ExperimentalFeatureAvailabilityApi::class) fun isHealthDataInBackgroundAvailable(call: MethodCall, result: Result) { scope.launch { result.success( @@ -247,6 +244,45 @@ class HealthDataOperations( } } + /** + * Deletes a specific health record by its client record ID and data type. Allows precise + * deletion of individual health records using client-side IDs. + * + * @param call Method call containing 'dataTypeKey', 'recordId', and 'clientRecordId' + * @param result Flutter result callback returning boolean success status + */ + fun deleteByClientRecordId(call: MethodCall, result: Result) { + val arguments = call.arguments as? HashMap<*, *> + val dataTypeKey = (arguments?.get("dataTypeKey") as? String)!! + val recordId = listOfNotNull(arguments["recordId"] as? String) + val clientRecordId = listOfNotNull(arguments["clientRecordId"] as? String) + if (!HealthConstants.mapToType.containsKey(dataTypeKey)) { + Log.w("FLUTTER_HEALTH::ERROR", "Datatype $dataTypeKey not found in HC") + result.success(false) + return + } + val classType = HealthConstants.mapToType[dataTypeKey]!! + + scope.launch { + try { + healthConnectClient.deleteRecords( + classType, + recordId, + clientRecordId + ) + result.success(true) + } catch (e: Exception) { + Log.e( + "FLUTTER_HEALTH::ERROR", + "Error deleting record with ClientRecordId: $clientRecordId" + ) + Log.e("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") + Log.e("FLUTTER_HEALTH::ERROR", e.stackTraceToString()) + result.success(false) + } + } + } + /** * Internal helper method to prepare Health Connect permission strings. Converts data type names * and access levels into proper permission format. diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt index ba0db624c..37eab3837 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Handler import android.util.Log import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.records.* import androidx.health.connect.client.request.AggregateGroupByDurationRequest import androidx.health.connect.client.request.AggregateRequest @@ -40,6 +41,7 @@ class HealthDataReader( */ fun getData(call: MethodCall, result: Result) { val dataType = call.argument("dataTypeKey")!! + val dataUnit: String? = call.argument("dataUnitKey") val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) val healthConnectData = mutableListOf>() @@ -47,12 +49,19 @@ class HealthDataReader( Log.i( "FLUTTER_HEALTH", - "Getting data for $dataType between $startTime and $endTime, filtering by $recordingMethodsToFilter" + "Getting data for $dataType with unit $dataUnit between $startTime and $endTime, filtering by $recordingMethodsToFilter" ) scope.launch { try { - HealthConstants.mapToType[dataType]?.let { classType -> + val grantedPermissions = healthConnectClient.permissionController.getGrantedPermissions() + + val authorizedTypeMap = HealthConstants.mapToType.filter { (typeKey, classType) -> + val requiredPermission = HealthPermission.getReadPermission(classType) + grantedPermissions.contains(requiredPermission) + } + + authorizedTypeMap[dataType]?.let { classType -> val records = mutableListOf() // Set up the initial request to read health records @@ -92,7 +101,7 @@ class HealthDataReader( ) for (rec in filteredRecords) { healthConnectData.addAll( - dataConverter.convertRecord(rec, dataType) + dataConverter.convertRecord(rec, dataType, dataUnit) ) } } @@ -105,6 +114,76 @@ class HealthDataReader( "Unable to return $dataType due to the following exception:" ) Log.e("FLUTTER_HEALTH::ERROR", Log.getStackTraceString(e)) + result.success(emptyList>()) // Return empty list instead of null + } + } + } + + /** + * Retrieves single health data point by given UUID and type. + * + * @param call Method call containing 'UUID' and 'dataTypeKey' + * @param result Flutter result callback returning list of health data maps + */ + fun getDataByUUID(call: MethodCall, result: Result) { + val dataType = call.argument("dataTypeKey")!! + val uuid = call.argument("uuid")!! + var healthPoint = mapOf() + + if (!HealthConstants.mapToType.containsKey(dataType)) { + Log.w("FLUTTER_HEALTH::ERROR", "Datatype $dataType not found in HC") + result.success(null) + return + } + + val classType = HealthConstants.mapToType[dataType]!! + + scope.launch { + try { + + Log.i("FLUTTER_HEALTH", "Getting $uuid with $classType") + + // Execute the request + val response = healthConnectClient.readRecord(classType, uuid) + + // Find the record with the matching UUID + val matchingRecord = response.record + + if (matchingRecord != null) { + // Handle special cases using shared logic + when (dataType) { + WORKOUT -> { + val tempData = mutableListOf>() + handleWorkoutData(listOf(matchingRecord), emptyList(), tempData) + healthPoint = if (tempData.isNotEmpty()) tempData[0] else mapOf() + } + SLEEP_SESSION, SLEEP_ASLEEP, SLEEP_AWAKE, SLEEP_AWAKE_IN_BED, + SLEEP_LIGHT, SLEEP_DEEP, SLEEP_REM, SLEEP_OUT_OF_BED, SLEEP_UNKNOWN -> { + if (matchingRecord is SleepSessionRecord) { + val tempData = mutableListOf>() + handleSleepData(listOf(matchingRecord), emptyList(), dataType, tempData) + healthPoint = if (tempData.isNotEmpty()) tempData[0] else mapOf() + } + } + else -> { + healthPoint = dataConverter.convertRecord(matchingRecord, dataType)[0] + } + } + + Log.i( + "FLUTTER_HEALTH", + "Success: $healthPoint" + ) + + Handler(context.mainLooper).run { result.success(healthPoint) } + } else { + Log.e("FLUTTER_HEALTH::ERROR", "Record not found for UUID: $uuid") + result.success(null) + } + } catch (e: Exception) { + Log.e("FLUTTER_HEALTH::ERROR", "Error fetching record with UUID: $uuid") + Log.e("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") + Log.e("FLUTTER_HEALTH::ERROR", e.stackTraceToString()) result.success(null) } } @@ -289,18 +368,22 @@ class HealthDataReader( * by querying related records within the workout time period. * * @param records List of ExerciseSessionRecord objects - * @param recordingMethodsToFilter Recording methods to exclude + * @param recordingMethodsToFilter Recording methods to exclude (empty list means no filtering) * @param healthConnectData Mutable list to append processed workout data */ private suspend fun handleWorkoutData( records: List, - recordingMethodsToFilter: List, + recordingMethodsToFilter: List = emptyList(), healthConnectData: MutableList> ) { - val filteredRecords = recordingFilter.filterRecordsByRecordingMethods( - recordingMethodsToFilter, + val filteredRecords = if (recordingMethodsToFilter.isEmpty()) { records - ) + } else { + recordingFilter.filterRecordsByRecordingMethods( + recordingMethodsToFilter, + records + ) + } for (rec in filteredRecords) { val record = rec as ExerciseSessionRecord @@ -366,8 +449,8 @@ class HealthDataReader( "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(), + "date_from" to record.startTime.toEpochMilli(), + "date_to" to record.endTime.toEpochMilli(), "source_id" to "", "source_name" to record.metadata.dataOrigin.packageName, ), @@ -381,20 +464,24 @@ class HealthDataReader( * Converts sleep stage enumerations to meaningful duration and type information. * * @param records List of SleepSessionRecord objects - * @param recordingMethodsToFilter Recording methods to exclude + * @param recordingMethodsToFilter Recording methods to exclude (empty list means no filtering) * @param dataType Specific sleep data type being requested * @param healthConnectData Mutable list to append processed sleep data */ private fun handleSleepData( records: List, - recordingMethodsToFilter: List, + recordingMethodsToFilter: List = emptyList(), dataType: String, healthConnectData: MutableList> ) { - val filteredRecords = recordingFilter.filterRecordsByRecordingMethods( - recordingMethodsToFilter, + val filteredRecords = if (recordingMethodsToFilter.isEmpty()) { records - ) + } else { + recordingFilter.filterRecordsByRecordingMethods( + recordingMethodsToFilter, + records + ) + } for (rec in filteredRecords) { if (rec is SleepSessionRecord) { diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt index a3e47beee..976a9bf15 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt @@ -3,29 +3,99 @@ package cachet.plugins.health import android.util.Log import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.records.* +import androidx.health.connect.client.records.metadata.Device import androidx.health.connect.client.records.metadata.Metadata import androidx.health.connect.client.units.* import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.Result +import java.time.Instant import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import java.time.Instant /** - * Handles writing health data to Health Connect. - * Manages data insertion for various health metrics, specialized records like workouts and nutrition, - * and proper data type conversion from Flutter to Health Connect format. + * Handles writing health data to Health Connect. Manages data insertion for various health metrics, + * specialized records like workouts and nutrition, and proper data type conversion from Flutter to + * Health Connect format. */ class HealthDataWriter( - private val healthConnectClient: HealthConnectClient, - private val scope: CoroutineScope + private val healthConnectClient: HealthConnectClient, + private val scope: CoroutineScope ) { - + + // Maps incoming recordingMethod int -> Metadata factory method. + // 0: unknown, 1: manual, 2: auto, 3: active (default unknown for others) + private fun buildMetadata( + recordingMethod: Int, + clientRecordId: String? = null, + clientRecordVersion: Long? = null, + deviceType: Int? = null, + ): Metadata { + // Device is required for auto/active; optional for manual/unknown + val deviceForAutoOrActive = + when (recordingMethod) { + RECORDING_METHOD_AUTOMATICALLY_RECORDED, + RECORDING_METHOD_ACTIVELY_RECORDED -> + Device(type = deviceType ?: Device.TYPE_UNKNOWN) + else -> null + } + + return when (recordingMethod) { + RECORDING_METHOD_MANUAL_ENTRY -> { + if (clientRecordId != null && clientRecordVersion != null) { + Metadata.manualEntry( + device = null, + clientRecordId = clientRecordId, + clientRecordVersion = clientRecordVersion, + ) + } else { + Metadata.manualEntry() + } + } + RECORDING_METHOD_AUTOMATICALLY_RECORDED -> { + val dev = deviceForAutoOrActive!! + if (clientRecordId != null && clientRecordVersion != null) { + Metadata.autoRecorded( + device = dev, + clientRecordId = clientRecordId, + clientRecordVersion = clientRecordVersion, + ) + } else { + Metadata.autoRecorded(dev) + } + } + RECORDING_METHOD_ACTIVELY_RECORDED -> { + val dev = deviceForAutoOrActive!! + if (clientRecordId != null && clientRecordVersion != null) { + Metadata.activelyRecorded( + device = dev, + clientRecordId = clientRecordId, + clientRecordVersion = clientRecordVersion, + ) + } else { + Metadata.activelyRecorded(dev) + } + } + else -> { // unknown + if (clientRecordId != null && clientRecordVersion != null) { + Metadata.unknownRecordingMethod( + device = null, + clientRecordId = clientRecordId, + clientRecordVersion = clientRecordVersion, + ) + } else { + Metadata.unknownRecordingMethod() + } + } + } + } + + /** - * Writes a single health data record to Health Connect. - * Supports most basic health metrics with automatic type conversion and validation. - * - * @param call Method call containing 'dataTypeKey', 'startTime', 'endTime', 'value', 'recordingMethod' + * Writes a single health data record to Health Connect. Supports most basic health metrics with + * automatic type conversion and validation. + * + * @param call Method call containing 'dataTypeKey', 'startTime', 'endTime', 'value', + * 'recordingMethod' * @param result Flutter result callback returning boolean success status */ fun writeData(call: MethodCall, result: Result) { @@ -33,15 +103,25 @@ class HealthDataWriter( val startTime = call.argument("startTime")!! val endTime = call.argument("endTime")!! val value = call.argument("value")!! + val clientRecordId: String? = call.argument("clientRecordId") + val clientRecordVersion: Double? = call.argument("clientRecordVersion") val recordingMethod = call.argument("recordingMethod")!! + val deviceType: Int? = call.argument("deviceType") Log.i( - "FLUTTER_HEALTH", - "Writing data for $type between $startTime and $endTime, value: $value, recording method: $recordingMethod" + "FLUTTER_HEALTH", + "Writing data for $type between $startTime and $endTime, value: $value, recording method: $recordingMethod" ) - val record = createRecord(type, startTime, endTime, value, recordingMethod) - + val metadata: Metadata = buildMetadata( + recordingMethod = recordingMethod, + clientRecordId = clientRecordId, + clientRecordVersion = clientRecordVersion?.toLong(), + deviceType = deviceType, + ) + + val record = createRecord(type, startTime, endTime, value, metadata) + if (record == null) { result.success(false) return @@ -59,13 +139,16 @@ class HealthDataWriter( } /** - * Writes a comprehensive workout session with optional distance and calorie data. - * Creates an ExerciseSessionRecord with associated DistanceRecord and TotalCaloriesBurnedRecord - * if supplementary data is provided. - * - * @param call Method call containing workout details: 'activityType', 'startTime', 'endTime', + * Writes a comprehensive workout session with optional distance and calorie data. Creates an + * ExerciseSessionRecord with associated DistanceRecord and TotalCaloriesBurnedRecord if + * supplementary data is provided. + * + * @param call Method call containing workout details: 'activityType', 'startTime', 'endTime', + * ``` * 'totalEnergyBurned', 'totalDistance', 'recordingMethod', 'title' - * @param result Flutter result callback returning boolean success status + * @param result + * ``` + * Flutter result callback returning boolean success status */ fun writeWorkoutData(call: MethodCall, result: Result) { val type = call.argument("activityType")!! @@ -74,80 +157,70 @@ class HealthDataWriter( val totalEnergyBurned = call.argument("totalEnergyBurned") val totalDistance = call.argument("totalDistance") val recordingMethod = call.argument("recordingMethod")!! - + val deviceType: Int? = call.argument("deviceType") + val workoutMetadata = buildMetadata(recordingMethod = recordingMethod, deviceType = deviceType) + if (!HealthConstants.workoutTypeMap.containsKey(type)) { result.success(false) - Log.w( - "FLUTTER_HEALTH::ERROR", - "[Health Connect] Workout type not supported" - ) + Log.w("FLUTTER_HEALTH::ERROR", "[Health Connect] Workout type not supported") return } - + val workoutType = HealthConstants.workoutTypeMap[type]!! val title = call.argument("title") ?: type scope.launch { try { val list = mutableListOf() - + // Add exercise session record list.add( - ExerciseSessionRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - exerciseType = workoutType, - title = title, - metadata = Metadata( - recordingMethod = recordingMethod, + ExerciseSessionRecord( + startTime = startTime, + startZoneOffset = null, + endTime = endTime, + endZoneOffset = null, + exerciseType = workoutType, + title = title, + metadata = workoutMetadata, ), - ), ) - + // Add distance record if provided if (totalDistance != null) { list.add( - DistanceRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - distance = Length.meters(totalDistance.toDouble()), - metadata = Metadata( - recordingMethod = recordingMethod, + DistanceRecord( + startTime = startTime, + startZoneOffset = null, + endTime = endTime, + endZoneOffset = null, + distance = Length.meters(totalDistance.toDouble()), + metadata = workoutMetadata, ), - ), ) } - + // Add energy burned record if provided if (totalEnergyBurned != null) { list.add( - TotalCaloriesBurnedRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - energy = Energy.kilocalories(totalEnergyBurned.toDouble()), - metadata = Metadata( - recordingMethod = recordingMethod, + TotalCaloriesBurnedRecord( + startTime = startTime, + startZoneOffset = null, + endTime = endTime, + endZoneOffset = null, + energy = Energy.kilocalories(totalEnergyBurned.toDouble()), + metadata = workoutMetadata, ), - ), ) } - + healthConnectClient.insertRecords(list) result.success(true) - Log.i( - "FLUTTER_HEALTH::SUCCESS", - "[Health Connect] Workout was successfully added!" - ) + 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()) @@ -157,10 +230,9 @@ class HealthDataWriter( } /** - * Writes blood pressure measurement with both systolic and diastolic values. - * Creates a single BloodPressureRecord containing both pressure readings - * taken at the same time point. - * + * Writes blood pressure measurement with both systolic and diastolic values. Creates a single + * BloodPressureRecord containing both pressure readings taken at the same time point. + * * @param call Method call containing 'systolic', 'diastolic', 'startTime', 'recordingMethod' * @param result Flutter result callback returning boolean success status */ @@ -169,31 +241,38 @@ class HealthDataWriter( val diastolic = call.argument("diastolic")!! val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) val recordingMethod = call.argument("recordingMethod")!! + val clientRecordId: String? = call.argument("clientRecordId") + val clientRecordVersion: Double? = call.argument("clientRecordVersion") + val deviceType: Int? = call.argument("deviceType") scope.launch { try { + val metadata: Metadata = buildMetadata( + recordingMethod = recordingMethod, + clientRecordId = clientRecordId, + clientRecordVersion = clientRecordVersion?.toLong(), + deviceType = deviceType, + ) healthConnectClient.insertRecords( - listOf( - BloodPressureRecord( - time = startTime, - systolic = Pressure.millimetersOfMercury(systolic), - diastolic = Pressure.millimetersOfMercury(diastolic), - zoneOffset = null, - metadata = Metadata( - recordingMethod = recordingMethod, - ), + listOf( + BloodPressureRecord( + time = startTime, + systolic = Pressure.millimetersOfMercury(systolic), + diastolic = Pressure.millimetersOfMercury(diastolic), + zoneOffset = null, + metadata = metadata, + ), ), - ), ) 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()) @@ -203,9 +282,9 @@ class HealthDataWriter( } /** - * Writes blood oxygen saturation measurement. - * Delegates to standard writeData method for OxygenSaturationRecord handling. - * + * Writes blood oxygen saturation measurement. Delegates to standard writeData method for + * OxygenSaturationRecord handling. + * * @param call Method call with blood oxygen data * @param result Flutter result callback returning success status */ @@ -214,9 +293,9 @@ class HealthDataWriter( } /** - * Writes menstrual flow data. - * Delegates to standard writeData method for MenstruationFlowRecord handling. - * + * Writes menstrual flow data. Delegates to standard writeData method for MenstruationFlowRecord + * handling. + * * @param call Method call with menstruation flow data * @param result Flutter result callback returning success status */ @@ -225,125 +304,137 @@ class HealthDataWriter( } /** - * Writes comprehensive nutrition/meal data with detailed nutrient breakdown. - * Creates NutritionRecord with extensive nutrient information including vitamins, - * minerals, macronutrients, and meal classification. - * - * @param call Method call containing nutrition data: calories, macronutrients, vitamins, + * Writes comprehensive nutrition/meal data with detailed nutrient breakdown. Creates + * NutritionRecord with extensive nutrient information including vitamins, minerals, + * macronutrients, and meal classification. + * + * @param call Method call containing nutrition data: calories, macronutrients, vitamins, + * ``` * minerals, meal details, timing information - * @param result Flutter result callback returning boolean success status + * @param result + * ``` + * Flutter result callback returning boolean success status */ fun writeMeal(call: MethodCall, result: Result) { val startTime = Instant.ofEpochMilli(call.argument("start_time")!!) val endTime = Instant.ofEpochMilli(call.argument("end_time")!!) val calories = call.argument("calories") - val protein = call.argument("protein") as Double? - val carbs = call.argument("carbs") as Double? - val fat = call.argument("fat") as Double? - val caffeine = call.argument("caffeine") as Double? - val vitaminA = call.argument("vitamin_a") as Double? - val b1Thiamine = call.argument("b1_thiamine") as Double? - val b2Riboflavin = call.argument("b2_riboflavin") as Double? - val b3Niacin = call.argument("b3_niacin") as Double? - val b5PantothenicAcid = call.argument("b5_pantothenic_acid") as Double? - val b6Pyridoxine = call.argument("b6_pyridoxine") as Double? - val b7Biotin = call.argument("b7_biotin") as Double? - val b9Folate = call.argument("b9_folate") as Double? - val b12Cobalamin = call.argument("b12_cobalamin") as Double? - val vitaminC = call.argument("vitamin_c") as Double? - val vitaminD = call.argument("vitamin_d") as Double? - val vitaminE = call.argument("vitamin_e") as Double? - val vitaminK = call.argument("vitamin_k") as Double? - val calcium = call.argument("calcium") as Double? - val chloride = call.argument("chloride") as Double? - val cholesterol = call.argument("cholesterol") as Double? - val chromium = call.argument("chromium") as Double? - val copper = call.argument("copper") as Double? - val fatUnsaturated = call.argument("fat_unsaturated") as Double? - val fatMonounsaturated = call.argument("fat_monounsaturated") as Double? - val fatPolyunsaturated = call.argument("fat_polyunsaturated") as Double? - val fatSaturated = call.argument("fat_saturated") as Double? - val fatTransMonoenoic = call.argument("fat_trans_monoenoic") as Double? - val fiber = call.argument("fiber") as Double? - val iodine = call.argument("iodine") as Double? - val iron = call.argument("iron") as Double? - val magnesium = call.argument("magnesium") as Double? - val manganese = call.argument("manganese") as Double? - val molybdenum = call.argument("molybdenum") as Double? - val phosphorus = call.argument("phosphorus") as Double? - val potassium = call.argument("potassium") as Double? - val selenium = call.argument("selenium") as Double? - val sodium = call.argument("sodium") as Double? - val sugar = call.argument("sugar") as Double? - val zinc = call.argument("zinc") as Double? + val protein = call.argument("protein") + val carbs = call.argument("carbs") + val fat = call.argument("fat") + val caffeine = call.argument("caffeine") + val vitaminA = call.argument("vitamin_a") + val b1Thiamine = call.argument("b1_thiamine") + val b2Riboflavin = call.argument("b2_riboflavin") + val b3Niacin = call.argument("b3_niacin") + val b5PantothenicAcid = call.argument("b5_pantothenic_acid") + val b6Pyridoxine = call.argument("b6_pyridoxine") + val b7Biotin = call.argument("b7_biotin") + val b9Folate = call.argument("b9_folate") + val b12Cobalamin = call.argument("b12_cobalamin") + val vitaminC = call.argument("vitamin_c") + val vitaminD = call.argument("vitamin_d") + val vitaminE = call.argument("vitamin_e") + val vitaminK = call.argument("vitamin_k") + val calcium = call.argument("calcium") + val chloride = call.argument("chloride") + val cholesterol = call.argument("cholesterol") + val chromium = call.argument("chromium") + val copper = call.argument("copper") + val fatUnsaturated = call.argument("fat_unsaturated") + val fatMonounsaturated = call.argument("fat_monounsaturated") + val fatPolyunsaturated = call.argument("fat_polyunsaturated") + val fatSaturated = call.argument("fat_saturated") + val fatTransMonoenoic = call.argument("fat_trans_monoenoic") + val fiber = call.argument("fiber") + val iodine = call.argument("iodine") + val iron = call.argument("iron") + val magnesium = call.argument("magnesium") + val manganese = call.argument("manganese") + val molybdenum = call.argument("molybdenum") + val phosphorus = call.argument("phosphorus") + val potassium = call.argument("potassium") + val selenium = call.argument("selenium") + val sodium = call.argument("sodium") + val sugar = call.argument("sugar") + val zinc = call.argument("zinc") val name = call.argument("name") val mealType = call.argument("meal_type")!! + val recordingMethod = call.argument("recordingMethod") ?: RECORDING_METHOD_MANUAL_ENTRY + val clientRecordId: String? = call.argument("clientRecordId") + val clientRecordVersion: Double? = call.argument("clientRecordVersion") + val deviceType: Int? = call.argument("deviceType") scope.launch { try { + val metadata: Metadata = buildMetadata( + recordingMethod = recordingMethod, + clientRecordId = clientRecordId, + clientRecordVersion = clientRecordVersion?.toLong(), + deviceType = deviceType, + ) val list = mutableListOf() + list.add( - NutritionRecord( - name = name, - energy = calories?.kilocalories, - totalCarbohydrate = carbs?.grams, - protein = protein?.grams, - totalFat = fat?.grams, - caffeine = caffeine?.grams, - vitaminA = vitaminA?.grams, - thiamin = b1Thiamine?.grams, - riboflavin = b2Riboflavin?.grams, - niacin = b3Niacin?.grams, - pantothenicAcid = b5PantothenicAcid?.grams, - vitaminB6 = b6Pyridoxine?.grams, - biotin = b7Biotin?.grams, - folate = b9Folate?.grams, - vitaminB12 = b12Cobalamin?.grams, - vitaminC = vitaminC?.grams, - vitaminD = vitaminD?.grams, - vitaminE = vitaminE?.grams, - vitaminK = vitaminK?.grams, - calcium = calcium?.grams, - chloride = chloride?.grams, - cholesterol = cholesterol?.grams, - chromium = chromium?.grams, - copper = copper?.grams, - unsaturatedFat = fatUnsaturated?.grams, - monounsaturatedFat = fatMonounsaturated?.grams, - polyunsaturatedFat = fatPolyunsaturated?.grams, - saturatedFat = fatSaturated?.grams, - transFat = fatTransMonoenoic?.grams, - dietaryFiber = fiber?.grams, - iodine = iodine?.grams, - iron = iron?.grams, - magnesium = magnesium?.grams, - manganese = manganese?.grams, - molybdenum = molybdenum?.grams, - phosphorus = phosphorus?.grams, - potassium = potassium?.grams, - selenium = selenium?.grams, - sodium = sodium?.grams, - sugar = sugar?.grams, - zinc = zinc?.grams, - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - mealType = HealthConstants.mapMealTypeToType[mealType] - ?: MealType.MEAL_TYPE_UNKNOWN, - ), + NutritionRecord( + name = name, + metadata = metadata, + energy = calories?.kilocalories, + totalCarbohydrate = carbs?.grams, + protein = protein?.grams, + totalFat = fat?.grams, + caffeine = caffeine?.grams, + vitaminA = vitaminA?.grams, + thiamin = b1Thiamine?.grams, + riboflavin = b2Riboflavin?.grams, + niacin = b3Niacin?.grams, + pantothenicAcid = b5PantothenicAcid?.grams, + vitaminB6 = b6Pyridoxine?.grams, + biotin = b7Biotin?.grams, + folate = b9Folate?.grams, + vitaminB12 = b12Cobalamin?.grams, + vitaminC = vitaminC?.grams, + vitaminD = vitaminD?.grams, + vitaminE = vitaminE?.grams, + vitaminK = vitaminK?.grams, + calcium = calcium?.grams, + chloride = chloride?.grams, + cholesterol = cholesterol?.grams, + chromium = chromium?.grams, + copper = copper?.grams, + unsaturatedFat = fatUnsaturated?.grams, + monounsaturatedFat = fatMonounsaturated?.grams, + polyunsaturatedFat = fatPolyunsaturated?.grams, + saturatedFat = fatSaturated?.grams, + transFat = fatTransMonoenoic?.grams, + dietaryFiber = fiber?.grams, + iodine = iodine?.grams, + iron = iron?.grams, + magnesium = magnesium?.grams, + manganese = manganese?.grams, + molybdenum = molybdenum?.grams, + phosphorus = phosphorus?.grams, + potassium = potassium?.grams, + selenium = selenium?.grams, + sodium = sodium?.grams, + sugar = sugar?.grams, + zinc = zinc?.grams, + startTime = startTime, + startZoneOffset = null, + endTime = endTime, + endZoneOffset = null, + mealType = HealthConstants.mapMealTypeToType[mealType] + ?: MealType.MEAL_TYPE_UNKNOWN + ), ) healthConnectClient.insertRecords(list) result.success(true) - Log.i( - "FLUTTER_HEALTH::SUCCESS", - "[Health Connect] Meal was successfully added!" - ) + 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()) @@ -353,46 +444,54 @@ class HealthDataWriter( } /** - * Writes speed/velocity data with multiple samples to Health Connect. - * Creates a SpeedRecord containing time-series speed measurements captured during - * activities like running, cycling, or walking. Each sample represents the user's - * instantaneous speed at a specific moment within the recording period. + * Writes speed/velocity data with multiple samples to Health Connect. Creates a SpeedRecord + * containing time-series speed measurements captured during activities like running, cycling, + * or walking. Each sample represents the user's instantaneous speed at a specific moment within + * the recording period. * * @param call Method call containing startTime, endTime, recordingMethod, + * ``` * samples: List> List of speed measurements, each * containing: time, speed (m/s) * - * @param result Flutter result callback returning boolean success status + * @param result + * ``` + * Flutter result callback returning boolean success status */ fun writeMultipleSpeedData(call: MethodCall, result: Result) { val startTime = call.argument("startTime")!! val endTime = call.argument("endTime")!! val samples = call.argument>>("samples")!! val recordingMethod = call.argument("recordingMethod")!! + val deviceType: Int? = call.argument("deviceType") scope.launch { try { - val speedSamples = samples.map { sample -> - SpeedRecord.Sample( - time = Instant.ofEpochMilli(sample["time"] as Long), - speed = Velocity.metersPerSecond(sample["speed"] as Double) - ) - } + val speedSamples = + samples.map { sample -> + SpeedRecord.Sample( + time = Instant.ofEpochMilli(sample["time"] as Long), + speed = Velocity.metersPerSecond(sample["speed"] as Double) + ) + } + + val metadata = buildMetadata(recordingMethod, deviceType = deviceType) - val speedRecord = SpeedRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - samples = speedSamples, - startZoneOffset = null, - endZoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) + val speedRecord = + SpeedRecord( + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + samples = speedSamples, + startZoneOffset = null, + endZoneOffset = null, + metadata = metadata, + ) healthConnectClient.insertRecords(listOf(speedRecord)) result.success(true) Log.i( - "FLUTTER_HEALTH::SUCCESS", - "Successfully wrote ${speedSamples.size} speed samples" + "FLUTTER_HEALTH::SUCCESS", + "Successfully wrote ${speedSamples.size} speed samples" ) } catch (e: Exception) { Log.e("FLUTTER_HEALTH::ERROR", "Error writing speed data: ${e.message}") @@ -401,13 +500,12 @@ class HealthDataWriter( } } - // ---------- Private Methods ---------- + // ---------- Private Methods ---------- /** - * Creates appropriate Health Connect record objects based on data type. - * Factory method that instantiates the correct record type with proper unit conversion - * and metadata assignment. - * + * Creates appropriate Health Connect record objects based on data type. Factory method that + * instantiates the correct record type with proper unit conversion and metadata assignment. + * * @param type Health data type string identifier * @param startTime Record start time in milliseconds * @param endTime Record end time in milliseconds @@ -416,254 +514,308 @@ class HealthDataWriter( * @return Record? Properly configured Health Connect record, or null if type unsupported */ private fun createRecord( - type: String, - startTime: Long, - endTime: Long, - value: Double, - recordingMethod: Int + type: String, + startTime: Long, + endTime: Long, + value: Double, + metadata: Metadata ): Record? { return when (type) { - BODY_FAT_PERCENTAGE -> BodyFatRecord( - time = Instant.ofEpochMilli(startTime), - percentage = Percentage(value), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - LEAN_BODY_MASS -> LeanBodyMassRecord( - time = Instant.ofEpochMilli(startTime), - mass = Mass.kilograms(value), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - HEIGHT -> HeightRecord( - time = Instant.ofEpochMilli(startTime), - height = Length.meters(value), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - WEIGHT -> WeightRecord( - time = Instant.ofEpochMilli(startTime), - weight = Mass.kilograms(value), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - STEPS -> StepsRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - count = value.toLong(), - startZoneOffset = null, - endZoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - ACTIVE_ENERGY_BURNED -> ActiveCaloriesBurnedRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - energy = Energy.kilocalories(value), - startZoneOffset = null, - endZoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - 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, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - BODY_TEMPERATURE -> BodyTemperatureRecord( - time = Instant.ofEpochMilli(startTime), - temperature = Temperature.celsius(value), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - BODY_WATER_MASS -> BodyWaterMassRecord( - time = Instant.ofEpochMilli(startTime), - mass = Mass.kilograms(value), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - BLOOD_OXYGEN -> OxygenSaturationRecord( - time = Instant.ofEpochMilli(startTime), - percentage = Percentage(value), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - BLOOD_GLUCOSE -> BloodGlucoseRecord( - time = Instant.ofEpochMilli(startTime), - level = BloodGlucose.milligramsPerDeciliter(value), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - HEART_RATE_VARIABILITY_RMSSD -> HeartRateVariabilityRmssdRecord( - time = Instant.ofEpochMilli(startTime), - heartRateVariabilityMillis = value, - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - DISTANCE_DELTA -> DistanceRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - distance = Length.meters(value), - startZoneOffset = null, - endZoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - WATER -> HydrationRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - volume = Volume.liters(value), - startZoneOffset = null, - endZoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - SLEEP_ASLEEP -> createSleepRecord(startTime, endTime, SleepSessionRecord.STAGE_TYPE_SLEEPING, recordingMethod) - SLEEP_LIGHT -> createSleepRecord(startTime, endTime, SleepSessionRecord.STAGE_TYPE_LIGHT, recordingMethod) - SLEEP_DEEP -> createSleepRecord(startTime, endTime, SleepSessionRecord.STAGE_TYPE_DEEP, recordingMethod) - SLEEP_REM -> createSleepRecord(startTime, endTime, SleepSessionRecord.STAGE_TYPE_REM, recordingMethod) - SLEEP_OUT_OF_BED -> createSleepRecord(startTime, endTime, SleepSessionRecord.STAGE_TYPE_OUT_OF_BED, recordingMethod) - SLEEP_AWAKE -> createSleepRecord(startTime, endTime, SleepSessionRecord.STAGE_TYPE_AWAKE, recordingMethod) - SLEEP_AWAKE_IN_BED -> createSleepRecord(startTime, endTime, SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED, recordingMethod) - SLEEP_UNKNOWN -> createSleepRecord(startTime, endTime, SleepSessionRecord.STAGE_TYPE_UNKNOWN, recordingMethod) - - SLEEP_SESSION -> SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - RESTING_HEART_RATE -> RestingHeartRateRecord( - time = Instant.ofEpochMilli(startTime), - beatsPerMinute = value.toLong(), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - BASAL_ENERGY_BURNED -> BasalMetabolicRateRecord( - time = Instant.ofEpochMilli(startTime), - basalMetabolicRate = Power.kilocaloriesPerDay(value), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - FLIGHTS_CLIMBED -> FloorsClimbedRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - floors = value, - startZoneOffset = null, - endZoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - RESPIRATORY_RATE -> RespiratoryRateRecord( - time = Instant.ofEpochMilli(startTime), - rate = value, - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - TOTAL_CALORIES_BURNED -> TotalCaloriesBurnedRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - energy = Energy.kilocalories(value), - startZoneOffset = null, - endZoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - MENSTRUATION_FLOW -> MenstruationFlowRecord( - time = Instant.ofEpochMilli(startTime), - flow = value.toInt(), - zoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - - SPEED -> SpeedRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - samples = listOf( - SpeedRecord.Sample( - time = Instant.ofEpochMilli(startTime), - speed = Velocity.metersPerSecond(value), + BODY_FAT_PERCENTAGE -> + BodyFatRecord( + time = Instant.ofEpochMilli(startTime), + percentage = Percentage(value), + zoneOffset = null, + metadata = metadata, + ) + LEAN_BODY_MASS -> + LeanBodyMassRecord( + time = Instant.ofEpochMilli(startTime), + mass = Mass.kilograms(value), + zoneOffset = null, + metadata = metadata, + ) + HEIGHT -> + HeightRecord( + time = Instant.ofEpochMilli(startTime), + height = Length.meters(value), + zoneOffset = null, + metadata = metadata, + ) + WEIGHT -> + WeightRecord( + time = Instant.ofEpochMilli(startTime), + weight = Mass.kilograms(value), + zoneOffset = null, + metadata = metadata, + ) + STEPS -> + StepsRecord( + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + count = value.toLong(), + startZoneOffset = null, + endZoneOffset = null, + metadata = metadata, + ) + ACTIVE_ENERGY_BURNED -> + ActiveCaloriesBurnedRecord( + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + energy = Energy.kilocalories(value), + startZoneOffset = null, + endZoneOffset = null, + metadata = metadata, + ) + 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, + metadata = metadata, + ) + BODY_TEMPERATURE -> + BodyTemperatureRecord( + time = Instant.ofEpochMilli(startTime), + temperature = Temperature.celsius(value), + zoneOffset = null, + metadata = metadata, + ) + BODY_WATER_MASS -> + BodyWaterMassRecord( + time = Instant.ofEpochMilli(startTime), + mass = Mass.kilograms(value), + zoneOffset = null, + metadata = metadata, + ) + BLOOD_OXYGEN -> + OxygenSaturationRecord( + time = Instant.ofEpochMilli(startTime), + percentage = Percentage(value), + zoneOffset = null, + metadata = metadata, + ) + BLOOD_GLUCOSE -> + BloodGlucoseRecord( + time = Instant.ofEpochMilli(startTime), + level = BloodGlucose.milligramsPerDeciliter(value), + zoneOffset = null, + metadata = metadata, + ) + HEART_RATE_VARIABILITY_RMSSD -> + HeartRateVariabilityRmssdRecord( + time = Instant.ofEpochMilli(startTime), + heartRateVariabilityMillis = value, + zoneOffset = null, + metadata = metadata, + ) + DISTANCE_DELTA -> + DistanceRecord( + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + distance = Length.meters(value), + startZoneOffset = null, + endZoneOffset = null, + metadata = metadata, + ) + WATER -> + HydrationRecord( + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + volume = Volume.liters(value), + startZoneOffset = null, + endZoneOffset = null, + metadata = metadata, + ) + SLEEP_ASLEEP -> + createSleepRecord( + startTime, + endTime, + SleepSessionRecord.STAGE_TYPE_SLEEPING, + metadata + ) + SLEEP_LIGHT -> + createSleepRecord( + startTime, + endTime, + SleepSessionRecord.STAGE_TYPE_LIGHT, + metadata + ) + SLEEP_DEEP -> + createSleepRecord( + startTime, + endTime, + SleepSessionRecord.STAGE_TYPE_DEEP, + metadata + ) + SLEEP_REM -> + createSleepRecord( + startTime, + endTime, + SleepSessionRecord.STAGE_TYPE_REM, + metadata + ) + SLEEP_OUT_OF_BED -> + createSleepRecord( + startTime, + endTime, + SleepSessionRecord.STAGE_TYPE_OUT_OF_BED, + metadata + ) + SLEEP_AWAKE -> + createSleepRecord( + startTime, + endTime, + SleepSessionRecord.STAGE_TYPE_AWAKE, + metadata + ) + SLEEP_AWAKE_IN_BED -> + createSleepRecord( + startTime, + endTime, + SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED, + metadata + ) + SLEEP_UNKNOWN -> + createSleepRecord( + startTime, + endTime, + SleepSessionRecord.STAGE_TYPE_UNKNOWN, + metadata + ) + SLEEP_SESSION -> + SleepSessionRecord( + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + startZoneOffset = null, + endZoneOffset = null, + metadata = metadata, + ) + RESTING_HEART_RATE -> + RestingHeartRateRecord( + time = Instant.ofEpochMilli(startTime), + beatsPerMinute = value.toLong(), + zoneOffset = null, + metadata = metadata, + ) + BASAL_ENERGY_BURNED -> + BasalMetabolicRateRecord( + time = Instant.ofEpochMilli(startTime), + basalMetabolicRate = Power.kilocaloriesPerDay(value), + zoneOffset = null, + metadata = metadata, + ) + FLIGHTS_CLIMBED -> + FloorsClimbedRecord( + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + floors = value, + startZoneOffset = null, + endZoneOffset = null, + metadata = metadata, + ) + RESPIRATORY_RATE -> + RespiratoryRateRecord( + time = Instant.ofEpochMilli(startTime), + rate = value, + zoneOffset = null, + metadata = metadata, + ) + TOTAL_CALORIES_BURNED -> + TotalCaloriesBurnedRecord( + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + energy = Energy.kilocalories(value), + startZoneOffset = null, + endZoneOffset = null, + metadata = metadata, + ) + MENSTRUATION_FLOW -> + MenstruationFlowRecord( + time = Instant.ofEpochMilli(startTime), + flow = value.toInt(), + zoneOffset = null, + metadata = metadata, + ) + SPEED -> + SpeedRecord( + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + samples = + listOf( + SpeedRecord.Sample( + time = Instant.ofEpochMilli(startTime), + speed = Velocity.metersPerSecond(value), + ) + ), + startZoneOffset = null, + endZoneOffset = null, + metadata = metadata, ) - ), - startZoneOffset = null, - endZoneOffset = null, - metadata = Metadata(recordingMethod = recordingMethod), - ) - BLOOD_PRESSURE_SYSTOLIC -> { Log.e("FLUTTER_HEALTH::ERROR", "You must use the [writeBloodPressure] API") null } - BLOOD_PRESSURE_DIASTOLIC -> { Log.e("FLUTTER_HEALTH::ERROR", "You must use the [writeBloodPressure] API") null } - WORKOUT -> { Log.e("FLUTTER_HEALTH::ERROR", "You must use the [writeWorkoutData] API") null } - NUTRITION -> { Log.e("FLUTTER_HEALTH::ERROR", "You must use the [writeMeal] API") null } - else -> { - Log.e("FLUTTER_HEALTH::ERROR", "The type $type was not supported by the Health plugin or you must use another API") + Log.e( + "FLUTTER_HEALTH::ERROR", + "The type $type was not supported by the Health plugin or you must use another API" + ) null } } } /** - * Creates sleep session records with stage information. - * Builds SleepSessionRecord with appropriate sleep stage data and timing. - * + * Creates sleep session records with stage information. Builds SleepSessionRecord with + * appropriate sleep stage data and timing. + * * @param startTime Sleep period start time in milliseconds * @param endTime Sleep period end time in milliseconds * @param stageType Sleep stage type constant * @param recordingMethod How sleep data was recorded * @return SleepSessionRecord Configured sleep session record */ - private fun createSleepRecord(startTime: Long, endTime: Long, stageType: Int, recordingMethod: Int): SleepSessionRecord { + private fun createSleepRecord( + startTime: Long, + endTime: Long, + stageType: Int, + metadata: Metadata + ): SleepSessionRecord { return SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stages = listOf( - SleepSessionRecord.Stage( - Instant.ofEpochMilli(startTime), - Instant.ofEpochMilli(endTime), - stageType - ) - ), - metadata = Metadata(recordingMethod = recordingMethod), + startTime = Instant.ofEpochMilli(startTime), + endTime = Instant.ofEpochMilli(endTime), + startZoneOffset = null, + endZoneOffset = null, + stages = + listOf( + SleepSessionRecord.Stage( + Instant.ofEpochMilli(startTime), + Instant.ofEpochMilli(endTime), + stageType + ) + ), + metadata = metadata, ) } @@ -694,7 +846,13 @@ class HealthDataWriter( private const val WORKOUT = "WORKOUT" private const val NUTRITION = "NUTRITION" private const val SPEED = "SPEED" - + + // Recording method mapping expected from Flutter side + private const val RECORDING_METHOD_UNKNOWN = 0 + private const val RECORDING_METHOD_MANUAL_ENTRY = 1 + private const val RECORDING_METHOD_AUTOMATICALLY_RECORDED = 2 + private const val RECORDING_METHOD_ACTIVELY_RECORDED = 3 + // Sleep types private const val SLEEP_ASLEEP = "SLEEP_ASLEEP" private const val SLEEP_LIGHT = "SLEEP_LIGHT" @@ -706,4 +864,4 @@ class HealthDataWriter( private const val SLEEP_UNKNOWN = "SLEEP_UNKNOWN" private const val SLEEP_SESSION = "SLEEP_SESSION" } -} \ No newline at end of file +} 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 27c94c76d..888240eee 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 @@ -151,6 +151,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // Reading data "getData" -> dataReader.getData(call, result) + "getDataByUUID" -> dataReader.getDataByUUID(call, result) "getIntervalData" -> dataReader.getIntervalData(call, result) "getAggregateData" -> dataReader.getAggregateData(call, result) "getTotalStepsInInterval" -> dataReader.getTotalStepsInInterval(call, result) @@ -168,6 +169,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // Deleting data "delete" -> dataOperations.deleteData(call, result) "deleteByUUID" -> dataOperations.deleteByUUID(call, result) + "deleteByClientRecordId" -> dataOperations.deleteByClientRecordId(call, result) else -> result.notImplemented() } } diff --git a/packages/health/example/android/app/build.gradle.kts b/packages/health/example/android/app/build.gradle.kts index b5cd45fa6..a9130cad1 100644 --- a/packages/health/example/android/app/build.gradle.kts +++ b/packages/health/example/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "cachet.plugins.health.health_example" compileSdk = flutter.compileSdkVersion - // ndkVersion = flutter.ndkVersion + ndkVersion = flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/packages/health/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/health/example/android/gradle/wrapper/gradle-wrapper.properties index afa1e8eb0..02767eb1c 100644 --- a/packages/health/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/health/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip diff --git a/packages/health/example/android/settings.gradle.kts b/packages/health/example/android/settings.gradle.kts index a439442c2..43394ed5e 100644 --- a/packages/health/example/android/settings.gradle.kts +++ b/packages/health/example/android/settings.gradle.kts @@ -18,8 +18,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false } include(":app") diff --git a/packages/health/example/ios/Runner.xcodeproj/project.pbxproj b/packages/health/example/ios/Runner.xcodeproj/project.pbxproj index 025a38b41..0b4f320d0 100644 --- a/packages/health/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/health/example/ios/Runner.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ 66EFA595EFC367CCF62B5486 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7CF05C63DD5841073CB4E39B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -326,6 +327,7 @@ }; 6FEBDBC7D4FF675303904EA3 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -371,7 +373,6 @@ runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; }; F39DD7443B254A13543A9E9D /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -505,7 +506,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 59TCTNUBMQ; + DEVELOPMENT_TEAM = 4A2HNSB52U; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -689,7 +690,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 59TCTNUBMQ; + DEVELOPMENT_TEAM = 4A2HNSB52U; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -713,7 +714,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 59TCTNUBMQ; + DEVELOPMENT_TEAM = 4A2HNSB52U; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -764,6 +765,10 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; ABB05D852D6BB16700FA4740 /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 8c3387da8..ffaeec3bf 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -202,6 +202,28 @@ class HealthAppState extends State { }); } + /// Fetch single data point by UUID and type. + Future fetchDataByUUID( + BuildContext context, { + required String uuid, + required HealthDataType type, + }) async { + try { + // fetch health data + HealthDataPoint? healthPoint = await health.getHealthDataByUUID( + uuid: uuid, + type: type, + ); + + if (healthPoint != null) { + // save all the new data points (only the first 100) + if (context.mounted) openDetailBottomSheet(context, healthPoint); + } + } catch (error) { + debugPrint("Exception in getHealthDataByUUID: $error"); + } + } + /// Add some random health data. /// Note that you should ensure that you have permissions to add the /// following data types. @@ -240,11 +262,12 @@ class HealthAppState extends State { 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, + clientRecordId: "uniqueID1234", + clientRecordVersion: 1); success &= await health.writeHealthData( value: 70, type: HealthDataType.HEART_RATE, @@ -308,12 +331,15 @@ class HealthAppState extends State { totalEnergyBurned: 400, ); success &= await health.writeBloodPressure( - systolic: 90, - diastolic: 80, - startTime: now, - ); + systolic: 90, + diastolic: 80, + startTime: now, + clientRecordId: "uniqueID1234", + clientRecordVersion: 2); success &= await health.writeMeal( mealType: MealType.SNACK, + clientRecordId: "uniqueID1234", + clientRecordVersion: 1.4, startTime: earlier, endTime: now, caloriesConsumed: 1000, @@ -579,6 +605,23 @@ class HealthAppState extends State { }); } + /// Display bottom sheet dialog of selected HealthDataPoint + void openDetailBottomSheet( + BuildContext context, + HealthDataPoint? healthPoint, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (BuildContext context) => _detailedBottomSheet( + healthPoint: healthPoint, + ), + ); + } + // UI building below @override @@ -783,46 +826,80 @@ 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}\n${p.recordingMethod}'), - ); - } - 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}\n${p.recordingMethod}'), - ); - } - 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}\n${p.recordingMethod}'), - ); - } - return ListTile( - title: Text("${p.typeString}: ${p.value}"), - trailing: Text(p.unitString), - subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'), - ); + Widget get _contentDataReady => Builder(builder: (context) { + return 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}\n${p.recordingMethod}'), + onTap: () { + fetchDataByUUID( + context, + uuid: p.uuid, + type: p.type, + ); + }, + ); + } + 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}\n${p.recordingMethod}'), + onTap: () { + fetchDataByUUID( + context, + uuid: p.uuid, + type: p.type, + ); + }, + ); + } + 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}\n${p.recordingMethod}'), + onTap: () { + fetchDataByUUID( + context, + uuid: p.uuid, + type: p.type, + ); + }, + ); + } + return ListTile( + title: Text("${p.typeString}: ${p.value}"), + trailing: Text(p.unitString), + subtitle: + Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'), + onTap: () { + fetchDataByUUID( + context, + uuid: p.uuid, + type: p.type, + ); + }, + ); + }); }); final Widget _contentNoData = const Text('No Data to show'); @@ -878,4 +955,49 @@ class HealthAppState extends State { AppState.PERMISSIONS_REVOKED => _permissionsRevoked, AppState.PERMISSIONS_NOT_REVOKED => _permissionsNotRevoked, }; + + Widget _detailedBottomSheet({HealthDataPoint? healthPoint}) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.5, + minChildSize: 0.3, + maxChildSize: 0.9, + builder: (BuildContext listContext, scrollController) { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text( + "Health Data Details", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + healthPoint == null + ? const Text('UUID Not Found!') + : Expanded( + child: ListView.builder( + controller: scrollController, + itemCount: healthPoint.toJson().entries.length, + itemBuilder: (context, index) { + String key = + healthPoint.toJson().keys.elementAt(index); + var value = healthPoint.toJson()[key]; + + return ListTile( + title: Text( + key.replaceAll('_', ' ').toUpperCase(), + style: + const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(value.toString()), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } } diff --git a/packages/health/example/pubspec.yaml b/packages/health/example/pubspec.yaml index 19f8b9007..581869077 100644 --- a/packages/health/example/pubspec.yaml +++ b/packages/health/example/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: "none" version: 4.5.0 environment: - sdk: ">=3.2.0 <4.0.0" + sdk: ">=3.8.0 <4.0.0" flutter: ">=3.6.0" dependencies: diff --git a/packages/health/ios/Classes/HealthDataReader.swift b/packages/health/ios/Classes/HealthDataReader.swift index 675c64460..c1b80183b 100644 --- a/packages/health/ios/Classes/HealthDataReader.swift +++ b/packages/health/ios/Classes/HealthDataReader.swift @@ -1,5 +1,5 @@ -import HealthKit import Flutter +import HealthKit /// Class responsible for reading health data from HealthKit class HealthDataReader { @@ -9,7 +9,7 @@ class HealthDataReader { let unitDict: [String: HKUnit] let workoutActivityTypeMap: [String: HKWorkoutActivityType] let characteristicsTypesDict: [String: HKCharacteristicType] - + /// - Parameters: /// - healthStore: The HealthKit store /// - dataTypesDict: Dictionary of data types @@ -17,12 +17,14 @@ class HealthDataReader { /// - unitDict: Dictionary of units /// - workoutActivityTypeMap: Dictionary of workout activity types /// - characteristicsTypesDict: Dictionary of characteristic types - init(healthStore: HKHealthStore, - dataTypesDict: [String: HKSampleType], - dataQuantityTypesDict: [String: HKQuantityType], - unitDict: [String: HKUnit], - workoutActivityTypeMap: [String: HKWorkoutActivityType], - characteristicsTypesDict: [String: HKCharacteristicType]) { + init( + healthStore: HKHealthStore, + dataTypesDict: [String: HKSampleType], + dataQuantityTypesDict: [String: HKQuantityType], + unitDict: [String: HKUnit], + workoutActivityTypeMap: [String: HKWorkoutActivityType], + characteristicsTypesDict: [String: HKCharacteristicType] + ) { self.healthStore = healthStore self.dataTypesDict = dataTypesDict self.dataQuantityTypesDict = dataQuantityTypesDict @@ -30,38 +32,42 @@ class HealthDataReader { self.workoutActivityTypeMap = workoutActivityTypeMap self.characteristicsTypesDict = characteristicsTypesDict } - + /// Gets health data /// - Parameters: /// - call: Flutter method call /// - result: Flutter result callback func getData(call: FlutterMethodCall, result: @escaping FlutterResult) { guard let arguments = call.arguments as? NSDictionary, - let dataTypeKey = arguments["dataTypeKey"] as? String else { + let dataTypeKey = arguments["dataTypeKey"] as? String + else { DispatchQueue.main.async { - result(FlutterError(code: "ARGUMENT_ERROR", - message: "Missing required dataTypeKey argument", - details: nil)) + result( + FlutterError( + code: "ARGUMENT_ERROR", + message: "Missing required dataTypeKey argument", + details: nil)) } return } - + let dataUnitKey = arguments["dataUnitKey"] as? String let startTime = (arguments["startTime"] as? NSNumber) ?? 0 let endTime = (arguments["endTime"] as? NSNumber) ?? 0 let limit = (arguments["limit"] as? Int) ?? HKObjectQueryNoLimit let recordingMethodsToFilter = (arguments["recordingMethodsToFilter"] as? [Int]) ?? [] - let includeManualEntry = !recordingMethodsToFilter.contains(HealthConstants.RecordingMethod.manual.rawValue) - + let includeManualEntry = !recordingMethodsToFilter.contains( + HealthConstants.RecordingMethod.manual.rawValue) + // convert from milliseconds to Date() let dateFrom = HealthUtilities.dateFromMilliseconds(startTime.doubleValue) let dateTo = HealthUtilities.dateFromMilliseconds(endTime.doubleValue) - + let sourceIdForCharacteristic = "com.apple.Health" let sourceNameForCharacteristic = "Health" - + // characteristic types checks (like GENDER, BLOOD_TYPE, etc.) - switch(dataTypeKey) { + switch dataTypeKey { case HealthConstants.BIRTH_DATE: let dateOfBirth = getBirthDate() result([ @@ -71,7 +77,7 @@ class HealthDataReader { "date_to": Int(dateTo.timeIntervalSince1970 * 1000), "source_id": sourceIdForCharacteristic, "source_name": sourceNameForCharacteristic, - "recording_method": HealthConstants.RecordingMethod.manual.rawValue + "recording_method": HealthConstants.RecordingMethod.manual.rawValue, ] ]) return @@ -84,7 +90,7 @@ class HealthDataReader { "date_to": Int(dateTo.timeIntervalSince1970 * 1000), "source_id": sourceIdForCharacteristic, "source_name": sourceNameForCharacteristic, - "recording_method": HealthConstants.RecordingMethod.manual.rawValue + "recording_method": HealthConstants.RecordingMethod.manual.rawValue, ] ]) return @@ -97,70 +103,79 @@ class HealthDataReader { "date_to": Int(dateTo.timeIntervalSince1970 * 1000), "source_id": sourceIdForCharacteristic, "source_name": sourceNameForCharacteristic, - "recording_method": HealthConstants.RecordingMethod.manual.rawValue + "recording_method": HealthConstants.RecordingMethod.manual.rawValue, ] ]) return default: break } - + guard let dataType = dataTypesDict[dataTypeKey] else { DispatchQueue.main.async { - result(FlutterError(code: "INVALID_TYPE", - message: "Invalid dataTypeKey: \(dataTypeKey)", - details: nil)) + result( + FlutterError( + code: "INVALID_TYPE", + message: "Invalid dataTypeKey: \(dataTypeKey)", + details: nil)) } return } - + var unit: HKUnit? if let dataUnitKey = dataUnitKey { unit = unitDict[dataUnitKey] } - + 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]) + 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] + sampleType: dataType, predicate: predicate, limit: limit, + sortDescriptors: [sortDescriptor] ) { x, samplesOrNil, error in - + guard error == nil else { DispatchQueue.main.async { - result(FlutterError(code: "HEALTH_ERROR", - message: "Error getting health data: \(error!.localizedDescription)", - details: nil)) + result( + FlutterError( + code: "HEALTH_ERROR", + message: "Error getting health data: \(error!.localizedDescription)", + details: nil)) } return } - + guard let samples = samplesOrNil else { DispatchQueue.main.async { result([]) } return } - + if let quantitySamples = samples as? [HKQuantitySample] { let dictionaries = quantitySamples.map { sample -> NSDictionary in return [ "uuid": "\(sample.uuid)", - "value": sample.quantity.doubleValue(for: unit ?? HKUnit.internationalUnit()), + "value": sample.quantity.doubleValue( + for: unit ?? HKUnit.internationalUnit()), "date_from": Int(sample.startDate.timeIntervalSince1970 * 1000), "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) - ? HealthConstants.RecordingMethod.manual.rawValue - : HealthConstants.RecordingMethod.automatic.rawValue, + "recording_method": + (sample.metadata?[HKMetadataKeyWasUserEntered] as? Bool == true) + ? HealthConstants.RecordingMethod.manual.rawValue + : HealthConstants.RecordingMethod.automatic.rawValue, "dataUnitKey": unit?.unitString, - "metadata": HealthUtilities.sanitizeMetadata(sample.metadata) + "metadata": HealthUtilities.sanitizeMetadata(sample.metadata), ] } DispatchQueue.main.async { @@ -194,7 +209,7 @@ class HealthDataReader { default: break } - + let categories = categorySamples.map { sample -> NSDictionary in return [ "uuid": "\(sample.uuid)", @@ -203,10 +218,11 @@ class HealthDataReader { "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) - ? HealthConstants.RecordingMethod.manual.rawValue - : HealthConstants.RecordingMethod.automatic.rawValue, - "metadata": HealthUtilities.sanitizeMetadata(sample.metadata) + "recording_method": + (sample.metadata?[HKMetadataKeyWasUserEntered] as? Bool == true) + ? HealthConstants.RecordingMethod.manual.rawValue + : HealthConstants.RecordingMethod.automatic.rawValue, + "metadata": HealthUtilities.sanitizeMetadata(sample.metadata), ] } DispatchQueue.main.async { @@ -219,7 +235,8 @@ class HealthDataReader { "workoutActivityType": self.workoutActivityTypeMap.first(where: { $0.value == sample.workoutActivityType })?.key, - "totalEnergyBurned": sample.totalEnergyBurned?.doubleValue(for: HKUnit.kilocalorie()), + "totalEnergyBurned": sample.totalEnergyBurned?.doubleValue( + for: HKUnit.kilocalorie()), "totalEnergyBurnedUnit": "KILOCALORIE", "totalDistance": sample.totalDistance?.doubleValue(for: HKUnit.meter()), "totalDistanceUnit": "METER", @@ -227,15 +244,19 @@ class HealthDataReader { "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) - ? HealthConstants.RecordingMethod.manual.rawValue - : HealthConstants.RecordingMethod.automatic.rawValue, + "recording_method": + (sample.metadata?[HKMetadataKeyWasUserEntered] as? Bool == true) + ? HealthConstants.RecordingMethod.manual.rawValue + : HealthConstants.RecordingMethod.automatic.rawValue, "workout_type": HKWorkoutActivityType.toString(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 + "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) } @@ -247,9 +268,11 @@ class HealthDataReader { for samplePoint in sample.sensitivityPoints { frequencies.append(samplePoint.frequency.doubleValue(for: HKUnit.hertz())) leftEarSensitivities.append( - samplePoint.leftEarSensitivity!.doubleValue(for: HKUnit.decibelHearingLevel())) + samplePoint.leftEarSensitivity!.doubleValue( + for: HKUnit.decibelHearingLevel())) rightEarSensitivities.append( - samplePoint.rightEarSensitivity!.doubleValue(for: HKUnit.decibelHearingLevel())) + samplePoint.rightEarSensitivity!.doubleValue( + for: HKUnit.decibelHearingLevel())) } return [ "uuid": "\(sample.uuid)", @@ -280,24 +303,32 @@ class HealthDataReader { "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) - ? HealthConstants.RecordingMethod.manual.rawValue - : HealthConstants.RecordingMethod.automatic.rawValue + "recording_method": + (sample.metadata?[HKMetadataKeyWasUserEntered] as? Bool == true) + ? HealthConstants.RecordingMethod.manual.rawValue + : HealthConstants.RecordingMethod.automatic.rawValue, ] for sample in samples { if let quantitySample = sample as? HKQuantitySample { for (key, identifier) in HealthConstants.NUTRITION_KEYS { - if (quantitySample.quantityType == HKObjectType.quantityType(forIdentifier: identifier)){ - let unit = key == "calories" ? HKUnit.kilocalorie() : key == "water" ? HKUnit.literUnit(with: .milli) : HKUnit.gram() - sampleDict[key] = quantitySample.quantity.doubleValue(for: unit) + if quantitySample.quantityType + == HKObjectType.quantityType(forIdentifier: identifier) + { + let unit = + key == "calories" + ? HKUnit.kilocalorie() + : key == "water" + ? HKUnit.literUnit(with: .milli) : HKUnit.gram() + sampleDict[key] = quantitySample.quantity.doubleValue( + for: unit) } } } } - foods.append(sampleDict as! [String : Any?]) + foods.append(sampleDict as! [String: Any?]) } } - + DispatchQueue.main.async { result(foods) } @@ -310,13 +341,283 @@ class HealthDataReader { result(nil) } } - + } } - + healthStore.execute(query) } - + + /// Gets single health data by UUID + /// - Parameters: + /// - call: Flutter method call + /// - result: Flutter result callback + func getDataByUUID(call: FlutterMethodCall, result: @escaping FlutterResult) { + + guard let arguments = call.arguments as? NSDictionary, + let uuidarg = arguments["uuid"] as? String, + let dataTypeKey = arguments["dataTypeKey"] as? String + else { + DispatchQueue.main.async { + result( + FlutterError( + code: "HEALTH_ERROR", + message: "Invalid Arguments - UUID or DataTypeKey invalid", + details: nil)) + } + return + } + + let dataUnitKey = arguments["dataUnitKey"] as? String + var unit: HKUnit? + if let dataUnitKey = dataUnitKey { + unit = unitDict[dataUnitKey] // Ensure unitDict exists and contains the key + } + + guard let dataType = dataTypesDict[dataTypeKey] else { + DispatchQueue.main.async { + result( + FlutterError( + code: "INVALID_TYPE", + message: "Invalid dataTypeKey: \(dataTypeKey)", + details: nil)) + } + return + } + + guard let uuid = UUID(uuidString: uuidarg) else { + result(nil) + return + } + + var predicate = HKQuery.predicateForObjects(with: [uuid]) + + let sourceIdForCharacteristic = "com.apple.Health" + let sourceNameForCharacteristic = "Health" + + let query = HKSampleQuery( + sampleType: dataType, + predicate: predicate, + limit: 1, + sortDescriptors: nil + ) { + [self] + x, samplesOrNil, error in + + guard error == nil else { + DispatchQueue.main.async { + result( + FlutterError( + code: "HEALTH_ERROR", + message: + "Error getting health data by UUID: \(error!.localizedDescription)", + details: nil)) + } + return + } + + guard let samples = samplesOrNil else { + DispatchQueue.main.async { + result(nil) + } + return + } + + if let quantitySamples = samples as? [HKQuantitySample] { + let dictionaries = quantitySamples.map { sample -> NSDictionary in + return [ + "uuid": "\(sample.uuid)", + "value": sample.quantity.doubleValue( + for: unit ?? HKUnit.internationalUnit()), + "date_from": Int(sample.startDate.timeIntervalSince1970 * 1000), + "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) + ? HealthConstants.RecordingMethod.manual.rawValue + : HealthConstants.RecordingMethod.automatic.rawValue, + "dataUnitKey": unit?.unitString, + "metadata": HealthUtilities.sanitizeMetadata(sample.metadata), + ] + } + DispatchQueue.main.async { + result(dictionaries.first) + } + } else if var categorySamples = samples as? [HKCategorySample] { + // filter category samples based on dataTypeKey + switch dataTypeKey { + case HealthConstants.SLEEP_IN_BED: + categorySamples = categorySamples.filter { $0.value == 0 } + case HealthConstants.SLEEP_ASLEEP: + categorySamples = categorySamples.filter { $0.value == 1 } + case HealthConstants.SLEEP_AWAKE: + categorySamples = categorySamples.filter { $0.value == 2 } + case HealthConstants.SLEEP_LIGHT: + categorySamples = categorySamples.filter { $0.value == 3 } + case HealthConstants.SLEEP_DEEP: + categorySamples = categorySamples.filter { $0.value == 4 } + case HealthConstants.SLEEP_REM: + categorySamples = categorySamples.filter { $0.value == 5 } + case HealthConstants.HEADACHE_UNSPECIFIED: + categorySamples = categorySamples.filter { $0.value == 0 } + case HealthConstants.HEADACHE_NOT_PRESENT: + categorySamples = categorySamples.filter { $0.value == 1 } + case HealthConstants.HEADACHE_MILD: + categorySamples = categorySamples.filter { $0.value == 2 } + case HealthConstants.HEADACHE_MODERATE: + categorySamples = categorySamples.filter { $0.value == 3 } + case HealthConstants.HEADACHE_SEVERE: + categorySamples = categorySamples.filter { $0.value == 4 } + default: + break + } + + let categories = categorySamples.map { sample -> NSDictionary in + return [ + "uuid": "\(sample.uuid)", + "value": sample.value, + "date_from": Int(sample.startDate.timeIntervalSince1970 * 1000), + "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) + ? HealthConstants.RecordingMethod.manual.rawValue + : HealthConstants.RecordingMethod.automatic.rawValue, + "metadata": HealthUtilities.sanitizeMetadata(sample.metadata), + ] + } + DispatchQueue.main.async { + result(categories.first) + } + } else if let workoutSamples = samples as? [HKWorkout] { + let dictionaries = workoutSamples.map { sample -> NSDictionary in + return [ + "uuid": "\(sample.uuid)", + "workoutActivityType": self.workoutActivityTypeMap.first(where: { + $0.value == sample.workoutActivityType + })?.key, + "totalEnergyBurned": sample.totalEnergyBurned?.doubleValue( + for: HKUnit.kilocalorie()), + "totalEnergyBurnedUnit": "KILOCALORIE", + "totalDistance": sample.totalDistance?.doubleValue(for: HKUnit.meter()), + "totalDistanceUnit": "METER", + "date_from": Int(sample.startDate.timeIntervalSince1970 * 1000), + "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) + ? HealthConstants.RecordingMethod.manual.rawValue + : HealthConstants.RecordingMethod.automatic.rawValue, + "workout_type": HKWorkoutActivityType.toString(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.first) + } + } else if let audiogramSamples = samples as? [HKAudiogramSample] { + let dictionaries = audiogramSamples.map { sample -> NSDictionary in + var frequencies = [Double]() + var leftEarSensitivities = [Double]() + var rightEarSensitivities = [Double]() + for samplePoint in sample.sensitivityPoints { + frequencies.append(samplePoint.frequency.doubleValue(for: HKUnit.hertz())) + leftEarSensitivities.append( + samplePoint.leftEarSensitivity!.doubleValue( + for: HKUnit.decibelHearingLevel())) + rightEarSensitivities.append( + samplePoint.rightEarSensitivity!.doubleValue( + for: HKUnit.decibelHearingLevel())) + } + return [ + "uuid": "\(sample.uuid)", + "frequencies": frequencies, + "leftEarSensitivities": leftEarSensitivities, + "rightEarSensitivities": rightEarSensitivities, + "date_from": Int(sample.startDate.timeIntervalSince1970 * 1000), + "date_to": Int(sample.endDate.timeIntervalSince1970 * 1000), + "source_id": sample.sourceRevision.source.bundleIdentifier, + "source_name": sample.sourceRevision.source.name, + ] + } + DispatchQueue.main.async { + result(dictionaries.first) + } + } else if let nutritionSamples = samples as? [HKCorrelation] { + var foods: [[String: Any?]] = [] + for food in nutritionSamples { + let name = food.metadata?[HKMetadataKeyFoodType] as? String + let mealType = food.metadata?["HKFoodMeal"] + let samples = food.objects + if let sample = samples.first as? HKQuantitySample { + var sampleDict = [ + "uuid": "\(sample.uuid)", + "name": name, + "meal_type": mealType, + "date_from": Int(sample.startDate.timeIntervalSince1970 * 1000), + "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) + ? HealthConstants.RecordingMethod.manual.rawValue + : HealthConstants.RecordingMethod.automatic.rawValue, + ] + for sample in samples { + if let quantitySample = sample as? HKQuantitySample { + for (key, identifier) in HealthConstants.NUTRITION_KEYS { + if quantitySample.quantityType + == HKObjectType.quantityType(forIdentifier: identifier) + { + let unit = + key == "calories" + ? HKUnit.kilocalorie() + : key == "water" + ? HKUnit.literUnit(with: .milli) : HKUnit.gram() + sampleDict[key] = quantitySample.quantity.doubleValue( + for: unit) + } + } + } + } + foods.append(sampleDict) + } + } + + DispatchQueue.main.async { + result(foods.first) + } + } else { + if #available(iOS 14.0, *), let ecgSamples = samples as? [HKElectrocardiogram] { + self.fetchEcgMeasurements(ecgSamples) { ecgDictionaries in + DispatchQueue.main.async { + if let dictionaries = ecgDictionaries as? [NSDictionary] { + result(dictionaries.first) + } else { + result(nil) + } + } + } + } else { + DispatchQueue.main.async { + print("Error getting ECG - only available on iOS 14.0 and above!") + result(nil) + } + } + } + } + + healthStore.execute(query) + } + /// Gets interval health data /// - Parameters: /// - call: Flutter method call @@ -329,32 +630,37 @@ class HealthDataReader { let endDate = (arguments?["endTime"] as? NSNumber) ?? 0 let intervalInSecond = (arguments?["interval"] as? Int) ?? 1 let recordingMethodsToFilter = (arguments?["recordingMethodsToFilter"] as? [Int]) ?? [] - let includeManualEntry = !recordingMethodsToFilter.contains(HealthConstants.RecordingMethod.manual.rawValue) - + let includeManualEntry = !recordingMethodsToFilter.contains( + HealthConstants.RecordingMethod.manual.rawValue) + // interval in seconds var interval = DateComponents() interval.second = intervalInSecond - + let dateFrom = HealthUtilities.dateFromMilliseconds(startDate.doubleValue) let dateTo = HealthUtilities.dateFromMilliseconds(endDate.doubleValue) - + guard let quantityType = dataQuantityTypesDict[dataTypeKey] else { DispatchQueue.main.async { - result(FlutterError(code: "INVALID_TYPE", - message: "Invalid dataTypeKey for interval query: \(dataTypeKey)", - details: nil)) + result( + FlutterError( + code: "INVALID_TYPE", + message: "Invalid dataTypeKey for interval query: \(dataTypeKey)", + details: nil)) } return } - + 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]) + if !includeManualEntry { + let manualPredicate = NSPredicate( + format: "metadata.%K != YES", HKMetadataKeyWasUserEntered) + predicate = NSCompoundPredicate( + type: .and, subpredicates: [predicate, manualPredicate]) } - + let statisticsOptions = statisticsOption(for: dataTypeKey) - + let query = HKStatisticsCollectionQuery( quantityType: quantityType, quantitySamplePredicate: predicate, @@ -362,35 +668,40 @@ class HealthDataReader { anchorDate: dateFrom, intervalComponents: interval ) - + query.initialResultsHandler = { [weak self] _, statisticCollectionOrNil, error in guard let self = self else { DispatchQueue.main.async { - result(FlutterError(code: "INTERNAL_ERROR", - message: "Internal instance reference lost", - details: nil)) + result( + FlutterError( + code: "INTERNAL_ERROR", + message: "Internal instance reference lost", + details: nil)) } return } - + if let error = error { DispatchQueue.main.async { - result(FlutterError(code: "STATISTICS_ERROR", - message: "Error getting statistics: \(error.localizedDescription)", - details: nil)) + result( + FlutterError( + code: "STATISTICS_ERROR", + message: "Error getting statistics: \(error.localizedDescription)", + details: nil)) } return } - + guard let collection = statisticCollectionOrNil else { DispatchQueue.main.async { result(nil) } return } - + var dictionaries = [[String: Any]]() - collection.enumerateStatistics(from: dateFrom, to: dateTo) { [weak self] statisticData, _ in + collection.enumerateStatistics(from: dateFrom, to: dateTo) { + [weak self] statisticData, _ in guard let self = self else { return } if let dataUnitKey = dataUnitKey, let unit = self.unitDict[dataUnitKey] { var value: Double? = nil @@ -412,7 +723,7 @@ class HealthDataReader { "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 ?? "" + "source_name": statisticData.sources?.first?.name ?? "", ] dictionaries.append(dict) } @@ -443,7 +754,7 @@ class HealthDataReader { return .cumulativeSum } } - + /// Gets total steps in interval /// - Parameters: /// - call: Flutter method call @@ -453,20 +764,23 @@ class HealthDataReader { let startTime = (arguments?["startTime"] as? NSNumber) ?? 0 let endTime = (arguments?["endTime"] as? NSNumber) ?? 0 let recordingMethodsToFilter = (arguments?["recordingMethodsToFilter"] as? [Int]) ?? [] - let includeManualEntry = !recordingMethodsToFilter.contains(HealthConstants.RecordingMethod.manual.rawValue) - + let includeManualEntry = !recordingMethodsToFilter.contains( + HealthConstants.RecordingMethod.manual.rawValue) + // Convert dates from milliseconds to Date() let dateFrom = HealthUtilities.dateFromMilliseconds(startTime.doubleValue) let dateTo = HealthUtilities.dateFromMilliseconds(endTime.doubleValue) - + let sampleType = HKQuantityType.quantityType(forIdentifier: .stepCount)! 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]) + if !includeManualEntry { + let manualPredicate = NSPredicate( + format: "metadata.%K != YES", HKMetadataKeyWasUserEntered) + predicate = NSCompoundPredicate( + type: .and, subpredicates: [predicate, manualPredicate]) } - + let query = HKStatisticsCollectionQuery( quantityType: sampleType, quantitySamplePredicate: predicate, @@ -478,13 +792,15 @@ class HealthDataReader { guard let results = results else { let errorMessage = error?.localizedDescription ?? "Unknown error" DispatchQueue.main.async { - result(FlutterError(code: "STEPS_ERROR", - message: "Error getting step count: \(errorMessage)", - details: nil)) + result( + FlutterError( + code: "STEPS_ERROR", + message: "Error getting step count: \(errorMessage)", + details: nil)) } return } - + var totalSteps = 0.0 results.enumerateStatistics(from: dateFrom, to: dateTo) { statistics, stop in if let quantity = statistics.sumQuantity() { @@ -492,15 +808,15 @@ class HealthDataReader { totalSteps += quantity.doubleValue(for: unit) } } - + DispatchQueue.main.async { result(Int(totalSteps)) } } - + healthStore.execute(query) } - + /// Gets birth date from HealthKit /// - Returns: Birth date private func getBirthDate() -> Date? { @@ -513,7 +829,7 @@ class HealthDataReader { } return dob } - + /// Gets gender from HealthKit /// - Returns: Biological sex private func getGender() -> HKBiologicalSex? { @@ -526,7 +842,7 @@ class HealthDataReader { } return bioSex } - + /// Gets blood type from HealthKit /// - Returns: Blood type private func getBloodType() -> HKBloodType? { @@ -539,12 +855,14 @@ class HealthDataReader { } return bloodType } - + /// Fetch ECG measurements from an HKElectrocardiogram sample /// - Parameter sample: ECG sample /// - Returns: Dictionary with ECG data @available(iOS 14.0, *) - private func fetchEcgMeasurements(_ ecgSample: [HKElectrocardiogram], _ result: @escaping FlutterResult) { + private func fetchEcgMeasurements( + _ ecgSample: [HKElectrocardiogram], _ result: @escaping FlutterResult + ) { let group = DispatchGroup() var dictionaries = [NSDictionary]() let lock = NSLock() @@ -553,19 +871,20 @@ class HealthDataReader { group.enter() var voltageValues = [[String: Any]]() - let expected = Int(ecg.numberOfVoltageMeasurements) - if expected > 0 { - voltageValues.reserveCapacity(expected) - } - + let expected = Int(ecg.numberOfVoltageMeasurements) + if expected > 0 { + voltageValues.reserveCapacity(expected) + } + let q = HKElectrocardiogramQuery(ecg) { _, res in switch res { case .measurement(let m): if let v = m.quantity(for: .appleWatchSimilarToLeadI)? - .doubleValue(for: HKUnit.volt()) { + .doubleValue(for: HKUnit.volt()) + { voltageValues.append([ "voltage": v, - "timeSinceSampleStart": m.timeSinceSampleStart + "timeSinceSampleStart": m.timeSinceSampleStart, ]) } case .done: @@ -573,15 +892,16 @@ class HealthDataReader { "uuid": "\(ecg.uuid)", "voltageValues": voltageValues, "averageHeartRate": ecg.averageHeartRate? - .doubleValue(for: HKUnit.count() - .unitDivided(by: HKUnit.minute())), + .doubleValue( + for: HKUnit.count() + .unitDivided(by: HKUnit.minute())), "samplingFrequency": ecg.samplingFrequency? .doubleValue(for: HKUnit.hertz()), "classification": ecg.classification.rawValue, "date_from": Int(ecg.startDate.timeIntervalSince1970 * 1000), "date_to": Int(ecg.endDate.timeIntervalSince1970 * 1000), "source_id": ecg.sourceRevision.source.bundleIdentifier, - "source_name": ecg.sourceRevision.source.name + "source_name": ecg.sourceRevision.source.name, ] lock.lock() dictionaries.append(dict) diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 52e68af12..d87e3cc09 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -77,6 +77,9 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { case "getData": healthDataReader.getData(call: call, result: result) + case "getDataByUUID": + healthDataReader.getDataByUUID(call: call, result: result) + case "getIntervalData": healthDataReader.getIntervalData(call: call, result: result) diff --git a/packages/health/ios/health.podspec b/packages/health/ios/health.podspec index f0eb7cd7a..a928ad6ad 100644 --- a/packages/health/ios/health.podspec +++ b/packages/health/ios/health.podspec @@ -3,7 +3,7 @@ # Pod::Spec.new do |s| s.name = 'health' - s.version = '13.1.4' + s.version = '13.2.0' s.summary = 'Wrapper for Apple\'s HealthKit on iOS and Google\'s Health Connect on Android.' s.description = <<-DESC Wrapper for Apple's HealthKit on iOS and Google's Health Connect on Android. diff --git a/packages/health/lib/health.g.dart b/packages/health/lib/health.g.dart index d12054286..0d8302430 100644 --- a/packages/health/lib/health.g.dart +++ b/packages/health/lib/health.g.dart @@ -167,6 +167,7 @@ const _$HealthDataUnitEnumMap = { HealthDataUnit.POUND: 'POUND', HealthDataUnit.STONE: 'STONE', HealthDataUnit.METER: 'METER', + HealthDataUnit.CENTIMETER: 'CENTIMETER', HealthDataUnit.INCH: 'INCH', HealthDataUnit.FOOT: 'FOOT', HealthDataUnit.YARD: 'YARD', @@ -208,6 +209,7 @@ const _$HealthDataUnitEnumMap = { HealthDataUnit.BEATS_PER_MINUTE: 'BEATS_PER_MINUTE', HealthDataUnit.RESPIRATIONS_PER_MINUTE: 'RESPIRATIONS_PER_MINUTE', HealthDataUnit.MILLIGRAM_PER_DECILITER: 'MILLIGRAM_PER_DECILITER', + HealthDataUnit.MILLIMOLES_PER_LITER: 'MILLIMOLES_PER_LITER', HealthDataUnit.METER_PER_SECOND: 'METER_PER_SECOND', HealthDataUnit.UNKNOWN_UNIT: 'UNKNOWN_UNIT', HealthDataUnit.NO_UNIT: 'NO_UNIT', diff --git a/packages/health/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index 374eb00a1..1255e7048 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -112,9 +112,7 @@ class HealthDataPoint { /// Create a [HealthDataPoint] based on a health data point from native data format. factory HealthDataPoint.fromHealthDataPoint( - HealthDataType dataType, - dynamic dataPoint, - ) { + HealthDataType dataType, dynamic dataPoint, String? unitName) { // Handling different [HealthValue] types HealthValue value = switch (dataType) { HealthDataType.AUDIOGRAM => @@ -141,7 +139,9 @@ class HealthDataPoint { final Map? metadata = dataPoint["metadata"] == null ? null : Map.from(dataPoint['metadata'] as Map); - final unit = dataTypeToUnit[dataType] ?? HealthDataUnit.UNKNOWN_UNIT; + final HealthDataUnit unit = HealthDataUnit.values.firstWhere( + (value) => value.name == unitName, + orElse: () => dataTypeToUnit[dataType] ?? HealthDataUnit.UNKNOWN_UNIT); final String? uuid = dataPoint["uuid"] as String?; final String? deviceModel = dataPoint["device_model"] as String?; diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 90e7a0cd5..622e3a937 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -46,7 +46,7 @@ class Health { /// Get an instance of the health plugin. Health({DeviceInfoPlugin? deviceInfo}) - : _deviceInfo = deviceInfo ?? DeviceInfoPlugin() { + : _deviceInfo = deviceInfo ?? DeviceInfoPlugin() { _registerFromJsonFunctions(); } @@ -107,13 +107,17 @@ class Health { await _checkIfHealthConnectAvailableOnAndroid(); if (permissions != null && permissions.length != types.length) { throw ArgumentError( - "The lists of types and permissions must be of same length."); + "The lists of types and permissions must be of same length.", + ); } final mTypes = List.from(types, growable: true); final mPermissions = permissions == null - ? List.filled(types.length, HealthDataAccess.READ.index, - growable: true) + ? 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 @@ -152,8 +156,9 @@ class Health { if (Platform.isIOS) return null; try { - final status = - await _channel.invokeMethod('getHealthConnectSdkStatus'); + final status = await _channel.invokeMethod( + 'getHealthConnectSdkStatus', + ); _healthConnectSdkStatus = status != null ? HealthConnectSdkStatus.fromNativeValue(status) : HealthConnectSdkStatus.sdkUnavailable; @@ -171,7 +176,7 @@ class Health { Future isHealthConnectAvailable() async => !Platform.isAndroid ? true : (await getHealthConnectSdkStatus() == - HealthConnectSdkStatus.sdkAvailable); + HealthConnectSdkStatus.sdkAvailable); /// Prompt the user to install the Google Health Connect app via the /// installed store (most likely Play Store). @@ -195,8 +200,9 @@ class Health { if (!(await isHealthConnectAvailable())) { throw UnsupportedError( - "Google Health Connect is not available on this Android device. " - "You may prompt the user to install it using the 'installHealthConnect' method"); + "Google Health Connect is not available on this Android device. " + "You may prompt the user to install it using the 'installHealthConnect' method", + ); } } @@ -210,12 +216,14 @@ class Health { if (Platform.isIOS) return false; try { - final status = - await _channel.invokeMethod('isHealthDataHistoryAvailable'); + final status = await _channel.invokeMethod( + 'isHealthDataHistoryAvailable', + ); return status ?? false; } catch (e) { debugPrint( - '$runtimeType - Exception in isHealthDataHistoryAvailable(): $e'); + '$runtimeType - Exception in isHealthDataHistoryAvailable(): $e', + ); return false; } } @@ -231,12 +239,14 @@ class Health { if (Platform.isIOS) return true; try { - final status = - await _channel.invokeMethod('isHealthDataHistoryAuthorized'); + final status = await _channel.invokeMethod( + 'isHealthDataHistoryAuthorized', + ); return status ?? false; } catch (e) { debugPrint( - '$runtimeType - Exception in isHealthDataHistoryAuthorized(): $e'); + '$runtimeType - Exception in isHealthDataHistoryAuthorized(): $e', + ); return false; } } @@ -254,12 +264,14 @@ class Health { await _checkIfHealthConnectAvailableOnAndroid(); try { - final bool? isAuthorized = - await _channel.invokeMethod('requestHealthDataHistoryAuthorization'); + final bool? isAuthorized = await _channel.invokeMethod( + 'requestHealthDataHistoryAuthorization', + ); return isAuthorized ?? false; } catch (e) { debugPrint( - '$runtimeType - Exception in requestHealthDataHistoryAuthorization(): $e'); + '$runtimeType - Exception in requestHealthDataHistoryAuthorization(): $e', + ); return false; } } @@ -274,12 +286,14 @@ class Health { if (Platform.isIOS) return false; try { - final status = await _channel - .invokeMethod('isHealthDataInBackgroundAvailable'); + final status = await _channel.invokeMethod( + 'isHealthDataInBackgroundAvailable', + ); return status ?? false; } catch (e) { debugPrint( - '$runtimeType - Exception in isHealthDataInBackgroundAvailable(): $e'); + '$runtimeType - Exception in isHealthDataInBackgroundAvailable(): $e', + ); return false; } } @@ -295,12 +309,14 @@ class Health { if (Platform.isIOS) return true; try { - final status = await _channel - .invokeMethod('isHealthDataInBackgroundAuthorized'); + final status = await _channel.invokeMethod( + 'isHealthDataInBackgroundAuthorized', + ); return status ?? false; } catch (e) { debugPrint( - '$runtimeType - Exception in isHealthDataInBackgroundAuthorized(): $e'); + '$runtimeType - Exception in isHealthDataInBackgroundAuthorized(): $e', + ); return false; } } @@ -318,12 +334,14 @@ class Health { await _checkIfHealthConnectAvailableOnAndroid(); try { - final bool? isAuthorized = await _channel - .invokeMethod('requestHealthDataInBackgroundAuthorization'); + final bool? isAuthorized = await _channel.invokeMethod( + 'requestHealthDataInBackgroundAuthorization', + ); return isAuthorized ?? false; } catch (e) { debugPrint( - '$runtimeType - Exception in requestHealthDataInBackgroundAuthorization(): $e'); + '$runtimeType - Exception in requestHealthDataInBackgroundAuthorization(): $e', + ); return false; } } @@ -355,7 +373,8 @@ class Health { await _checkIfHealthConnectAvailableOnAndroid(); if (permissions != null && permissions.length != types.length) { throw ArgumentError( - 'The length of [types] must be same as that of [permissions].'); + 'The length of [types] must be same as that of [permissions].', + ); } if (permissions != null) { @@ -370,15 +389,19 @@ class Health { type == HealthDataType.ATRIAL_FIBRILLATION_BURDEN) && permission != HealthDataAccess.READ) { throw ArgumentError( - 'Requesting WRITE permission on ELECTROCARDIOGRAM / HIGH_HEART_RATE_EVENT / LOW_HEART_RATE_EVENT / IRREGULAR_HEART_RATE_EVENT / WALKING_HEART_RATE / ATRIAL_FIBRILLATION_BURDEN is not allowed.'); + 'Requesting WRITE permission on ELECTROCARDIOGRAM / HIGH_HEART_RATE_EVENT / LOW_HEART_RATE_EVENT / IRREGULAR_HEART_RATE_EVENT / WALKING_HEART_RATE / ATRIAL_FIBRILLATION_BURDEN is not allowed.', + ); } } } final mTypes = List.from(types, growable: true); final mPermissions = permissions == null - ? List.filled(types.length, HealthDataAccess.READ.index, - growable: true) + ? 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 @@ -386,7 +409,9 @@ class Health { List keys = mTypes.map((e) => e.name).toList(); final bool? isAuthorized = await _channel.invokeMethod( - 'requestAuthorization', {'types': keys, "permissions": mPermissions}); + 'requestAuthorization', + {'types': keys, "permissions": mPermissions}, + ); return isAuthorized ?? false; } @@ -415,17 +440,25 @@ class Health { List recordingMethodsToFilter, ) async { List heights = await _prepareQuery( - startTime, endTime, HealthDataType.HEIGHT, recordingMethodsToFilter); + startTime, + endTime, + HealthDataType.HEIGHT, + recordingMethodsToFilter, + ); if (heights.isEmpty) { return []; } List weights = await _prepareQuery( - startTime, endTime, HealthDataType.WEIGHT, recordingMethodsToFilter); + startTime, + endTime, + HealthDataType.WEIGHT, + recordingMethodsToFilter, + ); - double h = - (heights.last.value as NumericHealthValue).numericValue.toDouble(); + double h = (heights.last.value as NumericHealthValue).numericValue + .toDouble(); const dataType = HealthDataType.BODY_MASS_INDEX; final unit = dataTypeToUnit[dataType]!; @@ -434,7 +467,7 @@ class Health { for (var i = 0; i < weights.length; i++) { final bmiValue = (weights[i].value as NumericHealthValue).numericValue.toDouble() / - (h * h); + (h * h); final x = HealthDataPoint( uuid: '', value: NumericHealthValue(numericValue: bmiValue), @@ -478,19 +511,24 @@ class Health { HealthDataUnit? unit, required HealthDataType type, required DateTime startTime, + String? clientRecordId, + double? clientRecordVersion, DateTime? endTime, RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { await _checkIfHealthConnectAvailableOnAndroid(); if (Platform.isIOS && - [RecordingMethod.active, RecordingMethod.unknown] - .contains(recordingMethod)) { + [ + 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."); + "Adding workouts should be done using the writeWorkoutData method.", + ); } // If not implemented on platform, throw an exception if (!isDataTypeAvailable(type)) { @@ -508,7 +546,8 @@ class Health { }.contains(type) && Platform.isIOS) { throw ArgumentError( - "$type - iOS does not support writing this data type in HealthKit"); + "$type - iOS does not support writing this data type in HealthKit", + ); } // Assign default unit if not specified @@ -537,6 +576,8 @@ class Health { 'startTime': startTime.millisecondsSinceEpoch, 'endTime': endTime.millisecondsSinceEpoch, 'recordingMethod': recordingMethod.toInt(), + 'clientRecordId': clientRecordId, + 'clientRecordVersion': clientRecordVersion, }; bool? success = await _channel.invokeMethod('writeData', args); return success ?? false; @@ -566,7 +607,7 @@ class Health { Map args = { 'dataTypeKey': type.name, 'startTime': startTime.millisecondsSinceEpoch, - 'endTime': endTime.millisecondsSinceEpoch + 'endTime': endTime.millisecondsSinceEpoch, }; bool? success = await _channel.invokeMethod('delete', args); return success ?? false; @@ -593,18 +634,32 @@ class Health { if (Platform.isIOS && type == null) { throw ArgumentError( - "On iOS, both UUID and type are required to delete a record."); + "On iOS, both UUID and type are required to delete a record.", + ); } - Map args = { - 'uuid': uuid, - 'dataTypeKey': type?.name, - }; + Map args = {'uuid': uuid, 'dataTypeKey': type?.name}; bool? success = await _channel.invokeMethod('deleteByUUID', args); return success ?? false; } + Future deleteByClientRecordId({ + required HealthDataType dataTypeKey, + required String clientRecordId, + String? recordId, + }) async { + await _checkIfHealthConnectAvailableOnAndroid(); + + Map args = { + 'dataTypeKey': dataTypeKey.name, + 'recordId': recordId, + 'clientRecordId': clientRecordId, + }; + bool? success = await _channel.invokeMethod('deleteByClientRecordId', args); + return success ?? false; + } + /// Saves a blood pressure record. /// /// Returns true if successful, false otherwise. @@ -623,13 +678,17 @@ class Health { required int systolic, required int diastolic, required DateTime startTime, + String? clientRecordId, + double? clientRecordVersion, DateTime? endTime, RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { await _checkIfHealthConnectAvailableOnAndroid(); if (Platform.isIOS && - [RecordingMethod.active, RecordingMethod.unknown] - .contains(recordingMethod)) { + [ + RecordingMethod.active, + RecordingMethod.unknown, + ].contains(recordingMethod)) { throw ArgumentError("recordingMethod must be manual or automatic on iOS"); } @@ -644,6 +703,8 @@ class Health { 'startTime': startTime.millisecondsSinceEpoch, 'endTime': endTime.millisecondsSinceEpoch, 'recordingMethod': recordingMethod.toInt(), + 'clientRecordId': clientRecordId, + 'clientRecordVersion': clientRecordVersion, }; return await _channel.invokeMethod('writeBloodPressure', args) == true; } @@ -669,8 +730,10 @@ class Health { }) async { await _checkIfHealthConnectAvailableOnAndroid(); if (Platform.isIOS && - [RecordingMethod.active, RecordingMethod.unknown] - .contains(recordingMethod)) { + [ + RecordingMethod.active, + RecordingMethod.unknown, + ].contains(recordingMethod)) { throw ArgumentError("recordingMethod must be manual or automatic on iOS"); } @@ -682,11 +745,12 @@ class Health { if (Platform.isIOS) { success = await writeHealthData( - value: saturation, - type: HealthDataType.BLOOD_OXYGEN, - startTime: startTime, - endTime: endTime, - recordingMethod: recordingMethod); + value: saturation, + type: HealthDataType.BLOOD_OXYGEN, + startTime: startTime, + endTime: endTime, + recordingMethod: recordingMethod, + ); } else if (Platform.isAndroid) { Map args = { 'value': saturation, @@ -757,6 +821,8 @@ class Health { required MealType mealType, required DateTime startTime, required DateTime endTime, + String? clientRecordId, + double? clientRecordVersion, double? caloriesConsumed, double? carbohydrates, double? protein, @@ -803,8 +869,10 @@ class Health { }) async { await _checkIfHealthConnectAvailableOnAndroid(); if (Platform.isIOS && - [RecordingMethod.active, RecordingMethod.unknown] - .contains(recordingMethod)) { + [ + RecordingMethod.active, + RecordingMethod.unknown, + ].contains(recordingMethod)) { throw ArgumentError("recordingMethod must be manual or automatic on iOS"); } @@ -817,6 +885,8 @@ class Health { 'meal_type': mealType.name, 'start_time': startTime.millisecondsSinceEpoch, 'end_time': endTime.millisecondsSinceEpoch, + 'clientRecordId': clientRecordId, + 'clientRecordVersion': clientRecordVersion, 'calories': caloriesConsumed, 'carbs': carbohydrates, 'protein': protein, @@ -884,17 +954,21 @@ class Health { }) async { await _checkIfHealthConnectAvailableOnAndroid(); if (Platform.isIOS && - [RecordingMethod.active, RecordingMethod.unknown] - .contains(recordingMethod)) { + [ + 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; + var value = Platform.isAndroid + ? MenstrualFlow.toHealthConnect(flow) + : flow.index; if (value == -1) { throw ArgumentError( - "$flow is not a valid menstrual flow value for $platformType"); + "$flow is not a valid menstrual flow value for $platformType", + ); } Map args = { @@ -936,12 +1010,14 @@ class Health { leftEarSensitivities.isEmpty || rightEarSensitivities.isEmpty) { throw ArgumentError( - "frequencies, leftEarSensitivities and rightEarSensitivities can't be empty"); + "frequencies, leftEarSensitivities and rightEarSensitivities can't be empty", + ); } if (frequencies.length != leftEarSensitivities.length || rightEarSensitivities.length != leftEarSensitivities.length) { throw ArgumentError( - "frequencies, leftEarSensitivities and rightEarSensitivities need to be of the same length"); + "frequencies, leftEarSensitivities and rightEarSensitivities need to be of the same length", + ); } endTime ??= startTime; if (startTime.isAfter(endTime)) { @@ -990,25 +1066,64 @@ class Health { if (Platform.isAndroid) { throw UnsupportedError( - "writeInsulinDelivery is not supported on Android"); + "writeInsulinDelivery is not supported on Android", + ); } Map args = { 'units': units, 'reason': reason.index, 'startTime': startTime.millisecondsSinceEpoch, - 'endTime': endTime.millisecondsSinceEpoch + 'endTime': endTime.millisecondsSinceEpoch, }; bool? success = await _channel.invokeMethod('writeInsulinDelivery', args); return success ?? false; } + /// [iOS only] Fetch a `HealthDataPoint` by `uuid` and `type`. Returns `null` if no matching record. + /// + /// Parameters: + /// * [uuid] - UUID of your saved health data point (e.g. A91A2F10-3D7B-486A-B140-5ADCD3C9C6D0) + /// * [type] - Data type of your saved health data point (e.g. HealthDataType.WORKOUT) + /// + /// Assuming above data are coming from your database. + /// + /// Note: this feature is only for iOS at this moment due to + /// requires refactoring for Android. + Future getHealthDataByUUID({ + required String uuid, + required HealthDataType type, + }) async { + if (uuid.isEmpty) { + throw HealthException(type, 'UUID is empty!'); + } + + await _checkIfHealthConnectAvailableOnAndroid(); + + // Ask for device ID only once + _deviceId ??= Platform.isAndroid + ? (await _deviceInfo.androidInfo).id + : (await _deviceInfo.iosInfo).identifierForVendor; + + // If not implemented on platform, throw an exception + if (!isDataTypeAvailable(type)) { + throw HealthException(type, 'Not available on platform $platformType'); + } + + final result = await _dataQueryByUUID(uuid, type); + + debugPrint('data by UUID: ${result?.toString()}'); + + return result; + } + /// 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, + Map? preferredUnits, required DateTime startTime, required DateTime endTime, List recordingMethodsToFilter = const [], @@ -1018,7 +1133,12 @@ class Health { for (var type in types) { final result = await _prepareQuery( - startTime, endTime, type, recordingMethodsToFilter); + startTime, + endTime, + type, + recordingMethodsToFilter, + dataUnit: preferredUnits?[type], + ); dataPoints.addAll(result); } @@ -1033,18 +1153,24 @@ 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, - List recordingMethodsToFilter = const []}) async { + Future> getHealthIntervalDataFromTypes({ + required DateTime startDate, + required DateTime endDate, + required List types, + required int interval, + List recordingMethodsToFilter = const [], + }) async { await _checkIfHealthConnectAvailableOnAndroid(); List dataPoints = []; for (var type in types) { final result = await _prepareIntervalQuery( - startDate, endDate, type, interval, recordingMethodsToFilter); + startDate, + endDate, + type, + interval, + recordingMethodsToFilter, + ); dataPoints.addAll(result); } @@ -1063,7 +1189,12 @@ class Health { List dataPoints = []; final result = await _prepareAggregateQuery( - startDate, endDate, types, activitySegmentDuration, includeManualEntry); + startDate, + endDate, + types, + activitySegmentDuration, + includeManualEntry, + ); dataPoints.addAll(result); return removeDuplicates(dataPoints); @@ -1074,8 +1205,9 @@ class Health { DateTime startTime, DateTime endTime, HealthDataType dataType, - List recordingMethodsToFilter, - ) async { + List recordingMethodsToFilter, { + HealthDataUnit? dataUnit, + }) async { // Ask for device ID only once _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id @@ -1084,7 +1216,9 @@ class Health { // 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 @@ -1092,16 +1226,22 @@ class Health { return _computeAndroidBMI(startTime, endTime, recordingMethodsToFilter); } return await _dataQuery( - startTime, endTime, dataType, recordingMethodsToFilter); + startTime, + endTime, + dataType, + recordingMethodsToFilter, + dataUnit: dataUnit, + ); } /// Prepares an interval query, i.e. checks if the types are available, etc. Future> _prepareIntervalQuery( - DateTime startDate, - DateTime endDate, - HealthDataType dataType, - int interval, - List recordingMethodsToFilter) async { + DateTime startDate, + DateTime endDate, + HealthDataType dataType, + int interval, + List recordingMethodsToFilter, + ) async { // Ask for device ID only once _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id @@ -1110,20 +1250,28 @@ class Health { // 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( - startDate, endDate, dataType, interval, recordingMethodsToFilter); + startDate, + endDate, + dataType, + interval, + recordingMethodsToFilter, + ); } /// 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 { + DateTime startDate, + DateTime endDate, + List dataTypes, + int activitySegmentDuration, + bool includeManualEntry, + ) async { // Ask for device ID only once _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id @@ -1136,23 +1284,32 @@ class Health { } } - return await _dataAggregateQuery(startDate, endDate, dataTypes, - activitySegmentDuration, includeManualEntry); + return await _dataAggregateQuery( + startDate, + endDate, + dataTypes, + activitySegmentDuration, + includeManualEntry, + ); } /// Fetches data points from Android/iOS native code. Future> _dataQuery( - DateTime startTime, - DateTime endTime, - HealthDataType dataType, - List recordingMethodsToFilter) async { + DateTime startTime, + DateTime endTime, + HealthDataType dataType, + List recordingMethodsToFilter, { + HealthDataUnit? dataUnit, + }) async { + String? unit = dataUnit?.name ?? dataTypeToUnit[dataType]?.name; final args = { 'dataTypeKey': dataType.name, - 'dataUnitKey': dataTypeToUnit[dataType]!.name, + 'dataUnitKey': unit, 'startTime': startTime.millisecondsSinceEpoch, 'endTime': endTime.millisecondsSinceEpoch, - 'recordingMethodsToFilter': - recordingMethodsToFilter.map((e) => e.toInt()).toList(), + 'recordingMethodsToFilter': recordingMethodsToFilter + .map((e) => e.toInt()) + .toList(), }; final fetchedDataPoints = await _channel.invokeMethod('getData', args); @@ -1160,6 +1317,7 @@ class Health { final msg = { "dataType": dataType, "dataPoints": fetchedDataPoints, + "unit": unit, }; const thresHold = 100; // If the no. of data points are larger than the threshold, @@ -1173,25 +1331,58 @@ class Health { } } + /// Fetches single data point by `uuid` and `type` from Android/iOS native code. + Future _dataQueryByUUID( + String uuid, + HealthDataType dataType, + ) async { + final args = { + 'dataTypeKey': dataType.name, + 'dataUnitKey': dataTypeToUnit[dataType]!.name, + 'uuid': uuid, + }; + + final fetchedDataPoint = await _channel.invokeMethod('getDataByUUID', args); + + // fetchedDataPoint is Map. // Must be converted to List first + // so no need to recreate _parse() to handle single HealthDataPoint. + + if (fetchedDataPoint != null) { + final msg = { + "dataType": dataType, + "dataPoints": [fetchedDataPoint], + }; + + // get single record of parsed fetchedDataPoints + return _parse(msg).first; + } else { + return null; + } + } + /// function for fetching statistic health data Future> _dataIntervalQuery( - DateTime startDate, - DateTime endDate, - HealthDataType dataType, - int interval, - List recordingMethodsToFilter) async { + DateTime startDate, + DateTime endDate, + HealthDataType dataType, + int interval, + List recordingMethodsToFilter, + ) async { final args = { 'dataTypeKey': dataType.name, 'dataUnitKey': dataTypeToUnit[dataType]!.name, 'startTime': startDate.millisecondsSinceEpoch, 'endTime': endDate.millisecondsSinceEpoch, 'interval': interval, - 'recordingMethodsToFilter': - recordingMethodsToFilter.map((e) => e.toInt()).toList(), + 'recordingMethodsToFilter': recordingMethodsToFilter + .map((e) => e.toInt()) + .toList(), }; - final fetchedDataPoints = - await _channel.invokeMethod('getIntervalData', args); + final fetchedDataPoints = await _channel.invokeMethod( + 'getIntervalData', + args, + ); if (fetchedDataPoints != null) { final msg = { "dataType": dataType, @@ -1204,21 +1395,24 @@ class Health { /// function for fetching statistic health data Future> _dataAggregateQuery( - DateTime startDate, - DateTime endDate, - List dataTypes, - int activitySegmentDuration, - bool includeManualEntry) async { + 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 + 'includeManualEntry': includeManualEntry, }; - final fetchedDataPoints = - await _channel.invokeMethod('getAggregateData', args); + final fetchedDataPoints = await _channel.invokeMethod( + 'getAggregateData', + args, + ); if (fetchedDataPoints != null) { final msg = { @@ -1233,10 +1427,13 @@ class Health { List _parse(Map message) { final dataType = message["dataType"] as HealthDataType; final dataPoints = message["dataPoints"] as List; + String? unit = message["unit"] as String?; return dataPoints - .map((dataPoint) => - HealthDataPoint.fromHealthDataPoint(dataType, dataPoint)) + .map( + (dataPoint) => + HealthDataPoint.fromHealthDataPoint(dataType, dataPoint, unit), + ) .toList(); } @@ -1246,8 +1443,11 @@ class Health { /// Get the total number of steps within a specific time period. /// Returns null if not successful. - Future getTotalStepsInInterval(DateTime startTime, DateTime endTime, - {bool includeManualEntry = true}) async { + Future getTotalStepsInInterval( + DateTime startTime, + DateTime endTime, { + bool includeManualEntry = true, + }) async { final args = { 'startTime': startTime.millisecondsSinceEpoch, 'endTime': endTime.millisecondsSinceEpoch, @@ -1264,20 +1464,22 @@ class Health { /// Assigns numbers to specific [HealthDataType]s. int _alignValue(HealthDataType type) => switch (type) { - HealthDataType.SLEEP_IN_BED => 0, - HealthDataType.SLEEP_ASLEEP => 1, - HealthDataType.SLEEP_AWAKE => 2, - HealthDataType.SLEEP_LIGHT => 3, - HealthDataType.SLEEP_DEEP => 4, - HealthDataType.SLEEP_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"), - }; + HealthDataType.SLEEP_IN_BED => 0, + HealthDataType.SLEEP_ASLEEP => 1, + HealthDataType.SLEEP_AWAKE => 2, + HealthDataType.SLEEP_LIGHT => 3, + HealthDataType.SLEEP_DEEP => 4, + HealthDataType.SLEEP_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 or Google Health Connect. /// @@ -1309,18 +1511,24 @@ class Health { }) async { await _checkIfHealthConnectAvailableOnAndroid(); if (Platform.isIOS && - [RecordingMethod.active, RecordingMethod.unknown] - .contains(recordingMethod)) { + [ + 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, - "Workout activity type $activityType is not supported on iOS"); + throw HealthException( + activityType, + "Workout activity type $activityType is not supported on iOS", + ); } else if (Platform.isAndroid && !_isOnAndroid(activityType)) { - throw HealthException(activityType, - "Workout activity type $activityType is not supported on Android"); + throw HealthException( + activityType, + "Workout activity type $activityType is not supported on Android", + ); } final args = { 'activityType': activityType.name, diff --git a/packages/health/lib/src/heath_data_types.dart b/packages/health/lib/src/heath_data_types.dart index ba605eb33..3d23592e3 100644 --- a/packages/health/lib/src/heath_data_types.dart +++ b/packages/health/lib/src/heath_data_types.dart @@ -404,6 +404,7 @@ enum HealthDataUnit { // Length units METER, + CENTIMETER, INCH, FOOT, YARD, @@ -468,6 +469,7 @@ enum HealthDataUnit { BEATS_PER_MINUTE, RESPIRATIONS_PER_MINUTE, MILLIGRAM_PER_DECILITER, + MILLIMOLES_PER_LITER, METER_PER_SECOND, UNKNOWN_UNIT, NO_UNIT, diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 90d81da50..a55907070 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -1,6 +1,6 @@ name: health description: Wrapper for Apple's HealthKit on iOS and Google's Health Connect on Android. -version: 13.1.4 +version: 13.2.0 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: @@ -11,9 +11,9 @@ dependencies: flutter: sdk: flutter intl: '>=0.18.0 <0.21.0' - device_info_plus: '>=9.0.0 <12.0.0' - json_annotation: ^4.8.0 - carp_serializable: ^2.0.0 # polymorphic json serialization + device_info_plus: '12.1.0' + json_annotation: ^4.9.0 + carp_serializable: ^2.0.1 # polymorphic json serialization dev_dependencies: flutter_test: