diff --git a/packages/health/README.md b/packages/health/README.md index 559ec09c4..afd9046e5 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -19,6 +19,10 @@ The plugin supports: Note that for Android, the target phone **needs** to have the [Health Connect](https://play.google.com/store/apps/details?id=com.google.android.apps.healthdata&hl=en) app installed (which is currently in beta) and have access to the internet. +### ⚠️ Breaking Changes: + +Starting on `12.1.0`, `writeHealthData` and `writeWorkoutData` will return `HealthDataPoint` instead of `bool`. Returns `null` if writing health data failed. + See the tables below for supported health and workout data types. ## Setup @@ -192,13 +196,15 @@ Below is a simplified flow of how to use the plugin. await health.requestAuthorization(types, permissions: permissions); // write steps and blood glucose - bool success = await health.writeHealthData(10, HealthDataType.STEPS, now, now); - success = await health.writeHealthData(3.1, HealthDataType.BLOOD_GLUCOSE, now, now); + HealthDataPoint? healthPoint = await health.writeHealthData(10, HealthDataType.STEPS, now, now); + healthPoint = await health.writeHealthData(3.1, HealthDataType.BLOOD_GLUCOSE, now, now); // you can also specify the recording method to store in the metadata (default is RecordingMethod.automatic) // on iOS only `RecordingMethod.automatic` and `RecordingMethod.manual` are supported // Android additionally supports `RecordingMethod.active` and `RecordingMethod.unknown` - success &= await health.writeHealthData(10, HealthDataType.STEPS, now, now, recordingMethod: RecordingMethod.manual); + healthPoint = await health.writeHealthData(10, HealthDataType.STEPS, now, now, recordingMethod: RecordingMethod.manual); + + bool success = healthPoint != null; // get the number of steps for today var midnight = DateTime(now.year, now.month, now.day); @@ -311,6 +317,22 @@ List points = ...; points = health.removeDuplicates(points); ``` +### 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: 'd7decd36-a26b-45a0-aa02-91484f8a17ca', + type: HealthDataType.STEPS, +); +``` +``` +I/FLUTTER_HEALTH( 9161): Success: {uuid=d7decd36-a26b-45a0-aa02-91484f8a17ca, 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. + ## Data Types The plugin supports the following [`HealthDataType`](https://pub.dev/documentation/health/latest/health/HealthDataType.html). 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 a9c23af2c..0db70a526 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 @@ -25,6 +25,7 @@ import androidx.health.connect.client.records.metadata.Metadata import androidx.health.connect.client.request.AggregateGroupByDurationRequest import androidx.health.connect.client.request.AggregateRequest import androidx.health.connect.client.request.ReadRecordsRequest +import androidx.health.connect.client.response.InsertRecordsResponse import androidx.health.connect.client.time.TimeRangeFilter import androidx.health.connect.client.units.* import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -157,6 +158,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "requestAuthorization" -> requestAuthorization(call, result) "revokePermissions" -> revokePermissions(call, result) "getData" -> getData(call, result) + "getDataByUUID" -> getDataByUUID(call, result) "getIntervalData" -> getIntervalData(call, result) "writeData" -> writeData(call, result) "delete" -> deleteData(call, result) @@ -905,6 +907,189 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } + private fun getDataByUUID(call: MethodCall, result: Result) { + val arguments = call.arguments as? HashMap<*, *> + val dataType = (arguments?.get("dataTypeKey") as? String)!! + val uuid = (arguments?.get("uuid") as? String)!! + var healthPoint = mapOf() + + if (!mapToType.containsKey(dataType)) { + Log.w("FLUTTER_HEALTH::ERROR", "Datatype $dataType not found in HC") + result.success(null) + return + } + + val classType = 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) { + // Workout needs distance and total calories burned too + if (dataType == WORKOUT) { + val record = matchingRecord as ExerciseSessionRecord + val distanceRequest = + healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = + DistanceRecord::class, + timeRangeFilter = + TimeRangeFilter.between( + record.startTime, + record.endTime, + ), + ), + ) + var totalDistance = 0.0 + for (distanceRec in distanceRequest.records) { + totalDistance += + distanceRec.distance + .inMeters + } + + val energyBurnedRequest = + healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = + TotalCaloriesBurnedRecord::class, + timeRangeFilter = + TimeRangeFilter.between( + record.startTime, + record.endTime, + ), + ), + ) + var totalEnergyBurned = 0.0 + for (energyBurnedRec in + energyBurnedRequest.records) { + totalEnergyBurned += + energyBurnedRec.energy + .inKilocalories + } + + val stepRequest = + healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = + StepsRecord::class, + timeRangeFilter = + TimeRangeFilter.between( + record.startTime, + record.endTime + ), + ), + ) + var totalSteps = 0.0 + for (stepRec in stepRequest.records) { + totalSteps += stepRec.count + } + + // val metadata = (rec as Record).metadata + // Add final datapoint + healthPoint = mapOf( + "uuid" to record.metadata.id, + "workoutActivityType" to + (workoutTypeMap + .filterValues { + it == + record.exerciseType + } + .keys + .firstOrNull() + ?: "OTHER"), + "totalDistance" to + if (totalDistance == + 0.0 + ) + null + else + totalDistance, + "totalDistanceUnit" to + "METER", + "totalEnergyBurned" to + if (totalEnergyBurned == + 0.0 + ) + null + else + totalEnergyBurned, + "totalEnergyBurnedUnit" to + "KILOCALORIE", + "totalSteps" to + if (totalSteps == + 0.0 + ) + null + else + totalSteps, + "totalStepsUnit" to + "COUNT", + "unit" to "MINUTES", + "date_from" to + matchingRecord.startTime + .toEpochMilli(), + "date_to" to + matchingRecord.endTime.toEpochMilli(), + "source_id" to "", + "source_name" to + record.metadata + .dataOrigin + .packageName, + ) + // Filter sleep stages for requested stage + } else if (classType == SleepSessionRecord::class) { + if (matchingRecord is SleepSessionRecord) { + if (dataType == SLEEP_SESSION) { + healthPoint = convertRecord( + matchingRecord, + dataType + )[0] + } else { + for (recStage in matchingRecord.stages) { + if (dataType == + mapSleepStageToType[ + recStage.stage] + ) { + healthPoint = convertRecordStage( + recStage, + dataType, + matchingRecord.metadata + )[0] + } + } + } + } + } else { + healthPoint = 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) + } + } + } + private fun convertRecordStage( stage: SleepSessionRecord.Stage, dataType: String, @@ -2224,8 +2409,23 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } scope.launch { try { - healthConnectClient.insertRecords(listOf(record)) - result.success(true) + // Insert records into Health Connect + val insertResponse: InsertRecordsResponse = healthConnectClient.insertRecords(listOf(record)) + // Log.i("FLUTTER_HEALTH::DEBUG", "Inserted records: $insertResponse") + + // Extract UUID from the first inserted record + val insertedUUID = insertResponse.recordIdsList.firstOrNull() ?: "" + + if (insertedUUID.isEmpty()) { + Log.e("FLUTTER_HEALTH::ERROR", "UUID is empty! No records were inserted.") + } + + Log.i( + "FLUTTER_HEALTH::SUCCESS", + "[Health Connect] Workout $insertedUUID was successfully added!" + ) + + result.success(insertedUUID) } catch (e: Exception) { result.success(false) } @@ -2302,14 +2502,24 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ), ) } - healthConnectClient.insertRecords( - list, - ) - result.success(true) + + // Insert records into Health Connect + val insertResponse: InsertRecordsResponse = healthConnectClient.insertRecords(list) + // Log.i("FLUTTER_HEALTH::DEBUG", "Inserted records: $insertResponse") + + // Extract UUID from the first inserted record + val insertedUUID = insertResponse.recordIdsList.firstOrNull() ?: "" + + if (insertedUUID.isEmpty()) { + Log.e("FLUTTER_HEALTH::ERROR", "UUID is empty! No records were inserted.") + } + Log.i( "FLUTTER_HEALTH::SUCCESS", - "[Health Connect] Workout was successfully added!" + "[Health Connect] Workout $insertedUUID was successfully added!" ) + + result.success(insertedUUID) } catch (e: Exception) { Log.w( "FLUTTER_HEALTH::ERROR", @@ -2317,7 +2527,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ) Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) - result.success(false) + result.success("") } } } diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index ccb27e727..e1c1305c8 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -10,7 +10,11 @@ import 'package:carp_serializable/carp_serializable.dart'; // Global Health instance final health = Health(); -void main() => runApp(HealthApp()); +void main() => runApp( + const MaterialApp( + home: HealthApp(), + ), + ); class HealthApp extends StatefulWidget { const HealthApp({super.key}); @@ -122,10 +126,9 @@ class HealthAppState extends State { try { authorized = await health.requestAuthorization(types, permissions: permissions); - + // request access to read historic data await health.requestHealthDataHistoryAuthorization(); - } catch (error) { debugPrint("Exception in authorize: $error"); } @@ -194,6 +197,28 @@ class HealthAppState extends State { }); } + /// Fetch single data point by UUID and type. + Future fetchDataByUUID({ + 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) + debugPrint('Fetching health data with ${healthPoint.toString()}'); + openDetailBottomSheet(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. @@ -209,94 +234,110 @@ class HealthAppState extends State { // misc. health data examples using the writeHealthData() method success &= await health.writeHealthData( - value: 1.925, - type: HealthDataType.HEIGHT, - startTime: earlier, - endTime: now, - recordingMethod: RecordingMethod.manual); + value: 1.925, + type: HealthDataType.HEIGHT, + startTime: earlier, + endTime: now, + recordingMethod: RecordingMethod.manual) != + null; success &= await health.writeHealthData( - value: 90, - type: HealthDataType.WEIGHT, - startTime: now, - recordingMethod: RecordingMethod.manual); + value: 90, + type: HealthDataType.WEIGHT, + startTime: now, + recordingMethod: RecordingMethod.manual) != + null; success &= await health.writeHealthData( - value: 90, - type: HealthDataType.HEART_RATE, - startTime: earlier, - endTime: now, - recordingMethod: RecordingMethod.manual); + value: 90, + type: HealthDataType.HEART_RATE, + startTime: earlier, + endTime: now, + recordingMethod: RecordingMethod.manual) != + null; success &= await health.writeHealthData( - value: 90, - type: HealthDataType.STEPS, - startTime: earlier, - endTime: now, - recordingMethod: RecordingMethod.manual); + value: 90, + type: HealthDataType.STEPS, + startTime: earlier, + endTime: now, + recordingMethod: RecordingMethod.manual) != + null; 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, + ) != + null; success &= await health.writeHealthData( - value: 70, - type: HealthDataType.HEART_RATE, - startTime: earlier, - endTime: now); + value: 70, + type: HealthDataType.HEART_RATE, + startTime: earlier, + endTime: now) != + null; if (Platform.isIOS) { success &= await health.writeHealthData( - value: 30, - type: HealthDataType.HEART_RATE_VARIABILITY_SDNN, - startTime: earlier, - endTime: now); + value: 30, + type: HealthDataType.HEART_RATE_VARIABILITY_SDNN, + startTime: earlier, + endTime: now) != + null; } else { success &= await health.writeHealthData( - value: 30, - type: HealthDataType.HEART_RATE_VARIABILITY_RMSSD, - startTime: earlier, - endTime: now); + value: 30, + type: HealthDataType.HEART_RATE_VARIABILITY_RMSSD, + startTime: earlier, + endTime: now) != + null; } success &= await health.writeHealthData( - value: 37, - type: HealthDataType.BODY_TEMPERATURE, - startTime: earlier, - endTime: now); + value: 37, + type: HealthDataType.BODY_TEMPERATURE, + startTime: earlier, + endTime: now) != + null; success &= await health.writeHealthData( - value: 105, - type: HealthDataType.BLOOD_GLUCOSE, - startTime: earlier, - endTime: now); + value: 105, + type: HealthDataType.BLOOD_GLUCOSE, + startTime: earlier, + endTime: now) != + null; success &= await health.writeHealthData( - value: 1.8, - type: HealthDataType.WATER, - startTime: earlier, - endTime: now); + value: 1.8, + type: HealthDataType.WATER, + startTime: earlier, + endTime: now) != + null; // different types of sleep success &= await health.writeHealthData( - value: 0.0, - type: HealthDataType.SLEEP_REM, - startTime: earlier, - endTime: now); + value: 0.0, + type: HealthDataType.SLEEP_REM, + startTime: earlier, + endTime: now) != + null; success &= await health.writeHealthData( - value: 0.0, - type: HealthDataType.SLEEP_ASLEEP, - startTime: earlier, - endTime: now); + value: 0.0, + type: HealthDataType.SLEEP_ASLEEP, + startTime: earlier, + endTime: now) != + null; success &= await health.writeHealthData( - value: 0.0, - type: HealthDataType.SLEEP_AWAKE, - startTime: earlier, - endTime: now); + value: 0.0, + type: HealthDataType.SLEEP_AWAKE, + startTime: earlier, + endTime: now) != + null; success &= await health.writeHealthData( - value: 0.0, - type: HealthDataType.SLEEP_DEEP, - startTime: earlier, - endTime: now); + value: 0.0, + type: HealthDataType.SLEEP_DEEP, + startTime: earlier, + endTime: now) != + null; success &= await health.writeHealthData( - value: 22, - type: HealthDataType.LEAN_BODY_MASS, - startTime: earlier, - endTime: now); + value: 22, + type: HealthDataType.LEAN_BODY_MASS, + startTime: earlier, + endTime: now) != + null; // specialized write methods success &= await health.writeBloodOxygen( @@ -305,13 +346,14 @@ class HealthAppState extends State { endTime: now, ); success &= await health.writeWorkoutData( - activityType: HealthWorkoutActivityType.AMERICAN_FOOTBALL, - title: "Random workout name that shows up in Health Connect", - start: now.subtract(const Duration(minutes: 15)), - end: now, - totalDistance: 2430, - totalEnergyBurned: 400, - ); + activityType: HealthWorkoutActivityType.AMERICAN_FOOTBALL, + title: "Random workout name that shows up in Health Connect", + start: now.subtract(const Duration(minutes: 15)), + end: now, + totalDistance: 2430, + totalEnergyBurned: 400, + ) != + null; success &= await health.writeBloodPressure( systolic: 90, diastolic: 80, @@ -391,24 +433,27 @@ class HealthAppState extends State { // Available on iOS 16.0+ only if (Platform.isIOS) { success &= await health.writeHealthData( - value: 22, - type: HealthDataType.WATER_TEMPERATURE, - startTime: earlier, - endTime: now, - recordingMethod: RecordingMethod.manual); + value: 22, + type: HealthDataType.WATER_TEMPERATURE, + startTime: earlier, + endTime: now, + recordingMethod: RecordingMethod.manual) != + null; success &= await health.writeHealthData( - value: 55, - type: HealthDataType.UNDERWATER_DEPTH, - startTime: earlier, - endTime: now, - recordingMethod: RecordingMethod.manual); + value: 55, + type: HealthDataType.UNDERWATER_DEPTH, + startTime: earlier, + endTime: now, + recordingMethod: RecordingMethod.manual) != + null; success &= await health.writeHealthData( - value: 4.3, - type: HealthDataType.UV_INDEX, - startTime: earlier, - endTime: now, - recordingMethod: RecordingMethod.manual); + value: 4.3, + type: HealthDataType.UV_INDEX, + startTime: earlier, + endTime: now, + recordingMethod: RecordingMethod.manual) != + null; } setState(() { @@ -511,6 +556,22 @@ class HealthAppState extends State { }); } + /// Display bottom sheet dialog of selected HealthDataPoint + void openDetailBottomSheet(HealthDataPoint? healthPoint) { + if (!context.mounted) return; + + 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 @@ -702,7 +763,8 @@ class HealthAppState extends State { Container( padding: const EdgeInsets.all(20), child: const CircularProgressIndicator( - strokeWidth: 10, + strokeWidth: 5, + strokeCap: StrokeCap.round, )), const Text('Fetching data...') ], @@ -723,6 +785,12 @@ class HealthAppState extends State { title: Text("${p.typeString}: ${p.value}"), trailing: Text(p.unitString), subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'), + onTap: () { + fetchDataByUUID( + uuid: p.uuid, + type: p.type, + ); + }, ); } if (p.value is WorkoutHealthValue) { @@ -732,6 +800,12 @@ class HealthAppState extends State { trailing: Text((p.value as WorkoutHealthValue).workoutActivityType.name), subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'), + onTap: () { + fetchDataByUUID( + uuid: p.uuid, + type: p.type, + ); + }, ); } if (p.value is NutritionHealthValue) { @@ -741,12 +815,25 @@ class HealthAppState extends State { trailing: Text('${(p.value as NutritionHealthValue).calories} kcal'), subtitle: Text('${p.dateFrom} - ${p.dateTo}\n${p.recordingMethod}'), + onTap: () { + fetchDataByUUID( + 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( + uuid: p.uuid, + type: p.type, + ); + }, + onLongPress: () => openDetailBottomSheet(p), ); }); @@ -803,4 +890,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/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 8b4fa1bbf..60665c169 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -247,6 +247,11 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { getData(call: call, result: result) } + /// Handle getData + else if call.method.elementsEqual("getDataByUUID") { + try! getDataByUUID(call: call, result: result) + } + /// Handle getIntervalData else if (call.method.elementsEqual("getIntervalData")){ getIntervalData(call: call, result: result) @@ -463,7 +468,17 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { print("Error Saving \(type) Sample: \(err.localizedDescription)") } DispatchQueue.main.async { - result(success) + if success { + // Return the UUID of the saved object + if let savedSample = sample as? HKObject { + print("Saved: \(savedSample.uuid.uuidString)") + result(savedSample.uuid.uuidString) // Return UUID as String + } else { + result("") + } + } + + result("") } }) } @@ -724,7 +739,17 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { print("Error Saving Workout. Sample: \(err.localizedDescription)") } DispatchQueue.main.async { - result(success) + if success { + // Return the UUID of the saved object + if let savedSample = workout as? HKWorkout { + print("Saved: \(savedSample.uuid.uuidString)") + result(savedSample.uuid.uuidString) // Return UUID as String + } else { + result("") + } + } + + result("") } }) } @@ -1122,6 +1147,238 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { return sanitizedArray } + func getDataByUUID(call: FlutterMethodCall, result: @escaping FlutterResult) throws { + + guard let arguments = call.arguments as? NSDictionary, + let uuidarg = arguments["uuid"] as? String, + let dataTypeKey = arguments["dataTypeKey"] as? String else { + throw PluginError(message: "Invalid Arguments - UUID or DataTypeKey invalid") + } + + let dataUnitKey = arguments["dataUnitKey"] as? String + let recordingMethodsToFilter = (arguments["recordingMethodsToFilter"] as? [Int]) ?? [] + let includeManualEntry = !recordingMethodsToFilter.contains(RecordingMethod.manual.rawValue) + + var unit: HKUnit? + if let dataUnitKey = dataUnitKey { + unit = unitDict[dataUnitKey] // Ensure unitDict exists and contains the key + } + + let dataTypeToFetch = dataTypeLookUp(key: dataTypeKey) + guard let uuid = UUID(uuidString: uuidarg) else { + result(false) + return + } + + var predicate = HKQuery.predicateForObjects(with: [uuid]) + + let sourceIdForCharacteristic = "com.apple.Health" + let sourceNameForCharacteristic = "Health" + + if (!includeManualEntry) { + let manualPredicate = NSPredicate(format: "metadata.%K != YES", HKMetadataKeyWasUserEntered) + predicate = NSCompoundPredicate(type: .and, subpredicates: [predicate, manualPredicate]) + } + + let query = HKSampleQuery( + sampleType: dataTypeToFetch, + predicate: predicate, + limit: 1, + sortDescriptors: nil + ) { + [self] + x, samplesOrNil, error in + + switch samplesOrNil { + case let (samples as [HKQuantitySample]) as Any: + let dictionaries = samples.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) + ? RecordingMethod.manual.rawValue + : RecordingMethod.automatic.rawValue, + "metadata": dataTypeKey == INSULIN_DELIVERY ? sample.metadata : nil, + "dataUnitKey": unit?.unitString + ] + } + DispatchQueue.main.async { + result(dictionaries.first) + } + + case var (samplesCategory as [HKCategorySample]) as Any: + + if dataTypeKey == self.SLEEP_IN_BED { + samplesCategory = samplesCategory.filter { $0.value == 0 } + } + if dataTypeKey == self.SLEEP_ASLEEP { + samplesCategory = samplesCategory.filter { $0.value == 1 } + } + if dataTypeKey == self.SLEEP_AWAKE { + samplesCategory = samplesCategory.filter { $0.value == 2 } + } + if dataTypeKey == self.SLEEP_LIGHT { + samplesCategory = samplesCategory.filter { $0.value == 3 } + } + if dataTypeKey == self.SLEEP_DEEP { + samplesCategory = samplesCategory.filter { $0.value == 4 } + } + if dataTypeKey == self.SLEEP_REM { + samplesCategory = samplesCategory.filter { $0.value == 5 } + } + if dataTypeKey == self.HEADACHE_UNSPECIFIED { + samplesCategory = samplesCategory.filter { $0.value == 0 } + } + if dataTypeKey == self.HEADACHE_NOT_PRESENT { + samplesCategory = samplesCategory.filter { $0.value == 1 } + } + if dataTypeKey == self.HEADACHE_MILD { + samplesCategory = samplesCategory.filter { $0.value == 2 } + } + if dataTypeKey == self.HEADACHE_MODERATE { + samplesCategory = samplesCategory.filter { $0.value == 3 } + } + if dataTypeKey == self.HEADACHE_SEVERE { + samplesCategory = samplesCategory.filter { $0.value == 4 } + } + let categories = samplesCategory.map { sample -> NSDictionary in + var metadata: [String: Any] = [:] + + if let sampleMetadata = sample.metadata { + for (key, value) in sampleMetadata { + metadata[key] = value + } + } + + 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) ? RecordingMethod.manual.rawValue : RecordingMethod.automatic.rawValue, + "metadata": metadata + ] + } + DispatchQueue.main.async { + result(categories.first) + } + + case let (samplesWorkout as [HKWorkout]) as Any: + + let dictionaries = samplesWorkout.map { sample -> NSDictionary in + return [ + "uuid": "\(sample.uuid)", + "workoutActivityType": 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) ? RecordingMethod.manual.rawValue : RecordingMethod.automatic.rawValue, + "workout_type": self.getWorkoutType(type: sample.workoutActivityType), + "total_distance": sample.totalDistance != nil ? Int(sample.totalDistance!.doubleValue(for: HKUnit.meter())) : 0, + "total_energy_burned": sample.totalEnergyBurned != nil ? Int(sample.totalEnergyBurned!.doubleValue(for: HKUnit.kilocalorie())) : 0 + ] + } + + DispatchQueue.main.async { + result(dictionaries.first) + } + + case let (samplesAudiogram as [HKAudiogramSample]) as Any: + let dictionaries = samplesAudiogram.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) + } + + case let (nutritionSample as [HKCorrelation]) as Any: + var foods: [[String: Any?]] = [] + for food in nutritionSample { + let name = food.metadata?[HKMetadataKeyFoodType] as? String + let mealType = food.metadata?["HKFoodMeal"] + let samples = food.objects + // get first sample if it exists + 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) + ? RecordingMethod.manual.rawValue + : RecordingMethod.automatic.rawValue + ] + for sample in samples { + if let quantitySample = sample as? HKQuantitySample { + for (key, identifier) in 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) + } + + default: + if #available(iOS 14.0, *), let ecgSamples = samplesOrNil as? [HKElectrocardiogram] { + let dictionaries = ecgSamples.map(fetchEcgMeasurements) + DispatchQueue.main.async { + result(dictionaries.first) + } + } else { + DispatchQueue.main.async { + print("Error getting ECG - only available on iOS 14.0 and above!") + result(nil) + } + } + } + } + + HKHealthStore().execute(query) + } + @available(iOS 14.0, *) private func fetchEcgMeasurements(_ sample: HKElectrocardiogram) -> NSDictionary { let semaphore = DispatchSemaphore(value: 0) diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 0c7bae82b..95f3b98fc 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -409,7 +409,7 @@ class Health { /// /// Values for Sleep and Headache are ignored and will be automatically assigned /// the default value. - Future writeHealthData({ + Future writeHealthData({ required double value, HealthDataUnit? unit, required HealthDataType type, @@ -474,8 +474,23 @@ class Health { 'endTime': endTime.millisecondsSinceEpoch, 'recordingMethod': recordingMethod.toInt(), }; - bool? success = await _channel.invokeMethod('writeData', args); - return success ?? false; + + String uuid = '${await _channel.invokeMethod('writeData', args)}'; + + if (uuid.isEmpty) { + return null; + } + + try { + final healthPoint = await getHealthDataByUUID( + uuid: uuid, + type: type, + ); + + return healthPoint; + } catch (e) { + return null; + } } /// Deletes all records of the given [type] for a given period of time. @@ -528,7 +543,8 @@ class Health { } if (Platform.isIOS && type == null) { - throw ArgumentError("On iOS, both UUID and type are required to delete a record."); + throw ArgumentError( + "On iOS, both UUID and type are required to delete a record."); } Map args = { @@ -616,12 +632,15 @@ class Health { bool? success; if (Platform.isIOS) { - success = await writeHealthData( - value: saturation, - type: HealthDataType.BLOOD_OXYGEN, - startTime: startTime, - endTime: endTime, - recordingMethod: recordingMethod); + final healthPoint = await writeHealthData( + value: saturation, + type: HealthDataType.BLOOD_OXYGEN, + startTime: startTime, + endTime: endTime, + recordingMethod: recordingMethod, + ); + + success = healthPoint != null; } else if (Platform.isAndroid) { Map args = { 'value': saturation, @@ -630,7 +649,9 @@ class Health { 'dataTypeKey': HealthDataType.BLOOD_OXYGEN.name, 'recordingMethod': recordingMethod.toInt(), }; - success = await _channel.invokeMethod('writeBloodOxygen', args); + // Check if UUID is not empty + success = + '${await _channel.invokeMethod('writeBloodOxygen', args)}'.isNotEmpty; } return success ?? false; } @@ -840,7 +861,10 @@ class Health { 'dataTypeKey': HealthDataType.MENSTRUATION_FLOW.name, 'recordingMethod': recordingMethod.toInt(), }; - return await _channel.invokeMethod('writeMenstruationFlow', args) == true; + + // Check if UUID is not empty + return '${await _channel.invokeMethod('writeMenstruationFlow', args)}' + .isNotEmpty; } /// Saves audiogram into Apple Health. Not supported on Android. @@ -965,6 +989,41 @@ class Health { return removeDuplicates(dataPoints); } + /// 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. + 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, + ); + + 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.Vkk @@ -1108,6 +1167,30 @@ 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); + + if (fetchedDataPoint != null) { + final msg = { + "dataType": dataType, + "dataPoints": [fetchedDataPoint], + }; + + return _parse(msg).first; + } else { + return null; + } + } + /// function for fetching statistic health data Future> _dataIntervalQuery( DateTime startDate, @@ -1231,7 +1314,7 @@ class Health { /// - [title] The title of the workout. /// *ONLY FOR HEALTH CONNECT* Default value is the [activityType], e.g. "STRENGTH_TRAINING". /// - [recordingMethod] The recording method of the data point, automatic by default (on iOS this can only be automatic or manual). - Future writeWorkoutData({ + Future writeWorkoutData({ required HealthWorkoutActivityType activityType, required DateTime start, required DateTime end, @@ -1268,7 +1351,25 @@ class Health { 'title': title, 'recordingMethod': recordingMethod.toInt(), }; - return await _channel.invokeMethod('writeWorkoutData', args) == true; + + String uuid = '${await _channel.invokeMethod('writeWorkoutData', args)}'; + + debugPrint('created uuid: $uuid'); + + if (uuid.isEmpty) { + return null; + } + + try { + final healthPoint = await getHealthDataByUUID( + uuid: uuid, + type: HealthDataType.WORKOUT, + ); + + return healthPoint; + } catch (e) { + return null; + } } /// Check if the given [HealthWorkoutActivityType] is supported on the iOS platform