From 37f4fcc6cd43708c2e37c4dd5cfe3527c9b0ab62 Mon Sep 17 00:00:00 2001 From: Agil Setiawan Date: Tue, 18 Mar 2025 15:47:12 +0700 Subject: [PATCH 1/9] Add getHealthDataByUUID method - Update README file - Update example app to show health point detail on the bottom sheet dialog - Adjust fetch data loading indicator sizing --- packages/health/README.md | 21 ++ .../cachet/plugins/health/HealthPlugin.kt | 203 +++++++++++++++ packages/health/example/lib/main.dart | 126 ++++++++- .../ios/Classes/SwiftHealthPlugin.swift | 240 ++++++++++++++++++ packages/health/lib/src/health_plugin.dart | 73 ++++++ 5 files changed, 659 insertions(+), 4 deletions(-) diff --git a/packages/health/README.md b/packages/health/README.md index 559ec09c4..a01fff793 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -311,6 +311,27 @@ 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`. + +**Android** doesn't expose an API to read single raw data by UUID. Instead, It is filtered by `HealthDataType type` and 5 minutes time range from `DateTime startTime` by default, then match and return a single record by UUID natively + +**iOS** has an ability to get a single record by providing UUID and `type`, so both `startTime` and `endTime` are optional. + +Please see example below: +```dart +HealthDataPoint? healthPoint = await health.getHealthDataByUUID( + uuid: 'd7decd36-a26b-45a0-aa02-91484f8a17ca', + type: HealthDataType.STEPS, + startTime: activity.startTime, +); +``` +``` +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`, `type` and `startTime` 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..c2f721bdd 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 @@ -157,6 +157,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 +906,208 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } + private fun getDataByUUID(call: MethodCall, result: Result) { + val arguments = call.arguments as? HashMap<*, *> + val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) + val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) + 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 { + // Set up the initial request to read health records with specified + // parameters + var request = + ReadRecordsRequest( + recordType = classType, + // Define the maximum amount of data + // that HealthConnect can return + // in a single request + timeRangeFilter = + TimeRangeFilter.between( + startTime, + endTime + ), + ) + + Log.i( + "FLUTTER_HEALTH", + "Getting $uuid between $startTime and $endTime" + ) + + // Execute the request + val response = healthConnectClient.readRecords(request) + + // Find the record with the matching UUID + val matchingRecord = response.records.firstOrNull { it.metadata.id == uuid } + + 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, diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index ccb27e727..793473e7b 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,31 @@ class HealthAppState extends State { }); } + /// Fetch single data point by UUID. + Future fetchDataByUUID({ + required String uuid, + required HealthDataType type, + DateTime? startTime, + }) async { + try { + // fetch health data + HealthDataPoint? healthPoint = await health.getHealthDataByUUID( + uuid: uuid, + type: type, + startTime: startTime, + ); + + 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. @@ -511,6 +539,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 +746,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 +768,13 @@ 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, + startTime: p.dateFrom, + ); + }, ); } if (p.value is WorkoutHealthValue) { @@ -732,6 +784,13 @@ 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, + startTime: p.dateFrom, + ); + }, ); } if (p.value is NutritionHealthValue) { @@ -741,12 +800,26 @@ 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, + startTime: p.dateFrom, + ); + }, ); } 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, + startTime: p.dateFrom, + ); + }, ); }); @@ -803,4 +876,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..1384cca01 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) @@ -1122,6 +1127,241 @@ 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 startTime = arguments["startTime"] as? NSNumber ?? 0 + let endTime = arguments["endTime"] as? NSNumber ?? 0 + 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..f2a53544a 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -965,6 +965,46 @@ class Health { return removeDuplicates(dataPoints); } + /// Fetch a health data points based on UUID and type. + /// + /// - `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) + /// - `startTime`: Start time of health data point. default is `DateTime.now()` + /// - `endTime`: End time of health data point. default is 5 minutes from `startTime` + /// + /// Assuming all above data are coming from your database. + /// + /// **Android** has no API to read a single raw data by UUID. + /// Instead, it is filtered by `type` and 5 minutes time range from `startTime` by default, + /// then match and return a single data by UUID natively + Future getHealthDataByUUID({ + required String uuid, + required HealthDataType type, + DateTime? startTime, + DateTime? endTime, + }) async { + 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, + startTime ?? DateTime.now(), + endTime, + ); + + 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 +1148,39 @@ class Health { } } + /// Fetches single data point by UUID from Android/iOS native code. + /// + /// `startTime` and `endTime` will be used for Android-only + Future _dataQueryByUUID( + String uuid, + HealthDataType dataType, + DateTime startTime, + DateTime? endTime, + ) async { + // Add 5 minutes from startTime to limit time range for performance purpose + final recordEndTime = endTime ?? startTime.add(Duration(minutes: 5)); + + final args = { + 'dataTypeKey': dataType.name, + 'dataUnitKey': dataTypeToUnit[dataType]!.name, + 'uuid': uuid, + 'startTime': startTime.millisecondsSinceEpoch, + 'endTime': recordEndTime.millisecondsSinceEpoch, + }; + 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, From 91de2cbcefb450ec8c8e30af61a054a0a1669518 Mon Sep 17 00:00:00 2001 From: Agil Setiawan Date: Wed, 19 Mar 2025 12:45:16 +0700 Subject: [PATCH 2/9] Update writeWorkoutData to return HealthDataPoint instead --- .../cachet/plugins/health/HealthPlugin.kt | 23 +++++++++++++----- .../ios/Classes/SwiftHealthPlugin.swift | 24 +++++++++++++++++-- packages/health/lib/src/health_plugin.dart | 13 ++++++++-- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt index c2f721bdd..067481f3d 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 @@ -2505,14 +2506,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", @@ -2520,7 +2531,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/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 1384cca01..b2dfe1ce2 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -468,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? HKSample { + print("Saved: \(savedSample.uuid.uuidString)") + result(savedSample.uuid.uuidString) // Return UUID as String + } else { + result("") + } + } + + result("") } }) } @@ -729,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("") } }) } diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index f2a53544a..53acb36b5 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -1304,7 +1304,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, @@ -1341,7 +1341,16 @@ class Health { 'title': title, 'recordingMethod': recordingMethod.toInt(), }; - return await _channel.invokeMethod('writeWorkoutData', args) == true; + + String uuid = '${await _channel.invokeMethod('writeWorkoutData', args)}'; + + final healthPoint = await getHealthDataByUUID( + uuid: uuid, + type: HealthDataType.WORKOUT, + startTime: start, + ); + + return healthPoint; } /// Check if the given [HealthWorkoutActivityType] is supported on the iOS platform From 158ace94b8d367fb1faae84064cd9ab77e5ca6ed Mon Sep 17 00:00:00 2001 From: Agil Setiawan Date: Wed, 19 Mar 2025 14:38:33 +0700 Subject: [PATCH 3/9] Update writeHealthData to return HealthDataPoint instead --- .../cachet/plugins/health/HealthPlugin.kt | 19 +++++++++-- .../ios/Classes/SwiftHealthPlugin.swift | 2 +- packages/health/lib/src/health_plugin.dart | 32 +++++++++++++------ 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt index 067481f3d..157c32d7c 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 @@ -2428,8 +2428,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) } diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index b2dfe1ce2..9eb583490 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -470,7 +470,7 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { DispatchQueue.main.async { if success { // Return the UUID of the saved object - if let savedSample = sample as? HKSample { + if let savedSample = sample as? HKObject { print("Saved: \(savedSample.uuid.uuidString)") result(savedSample.uuid.uuidString) // Return UUID as String } else { diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 53acb36b5..01682da1c 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,16 @@ 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)}'; + + final healthPoint = await getHealthDataByUUID( + uuid: uuid, + type: type, + startTime: startTime, + ); + + return healthPoint; } /// Deletes all records of the given [type] for a given period of time. @@ -528,7 +536,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 +625,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, From cebb07f825969665a0ec9d7d376e2bce598ee0f3 Mon Sep 17 00:00:00 2001 From: Agil Setiawan Date: Wed, 19 Mar 2025 14:51:18 +0700 Subject: [PATCH 4/9] Update README - Add breaking changes section - Update writeHealthData usage example --- packages/health/README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/health/README.md b/packages/health/README.md index a01fff793..9fc85e90f 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); From 460c035e97a81fcb93e5c93211caad3923cbb2e9 Mon Sep 17 00:00:00 2001 From: Agil Setiawan Date: Wed, 19 Mar 2025 15:05:25 +0700 Subject: [PATCH 5/9] Keep writeBloodOxygen and writeMenstruationFlow to return bool --- packages/health/lib/src/health_plugin.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 01682da1c..9fe63a2e5 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -642,7 +642,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; } @@ -852,7 +854,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. From 7b5d358d80f9a29d0c1bfb1a6368b49a1727b3d1 Mon Sep 17 00:00:00 2001 From: Agil Setiawan Date: Wed, 19 Mar 2025 15:07:23 +0700 Subject: [PATCH 6/9] Update implementation on writing data examples --- packages/health/example/lib/main.dart | 200 ++++++++++++++------------ 1 file changed, 110 insertions(+), 90 deletions(-) diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 793473e7b..d6dcff05e 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -237,94 +237,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( @@ -333,13 +349,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, @@ -419,24 +436,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(() { From de5afdc0a331561680fe85c4fe906df16b4736b8 Mon Sep 17 00:00:00 2001 From: Agil Setiawan Date: Thu, 20 Mar 2025 08:57:24 +0700 Subject: [PATCH 7/9] Update get health data point by uuid - Remove start and end time on native methods - Android: change readRecords() to readRecord() - Update inline documentation on health_plugin - Update README - Example: remove startTime from fetchDataByUUID method - Add an ability to openDetailBottomSheet onLongPress with existing HealthDataPoint for preview purpose --- packages/health/README.md | 7 +--- .../cachet/plugins/health/HealthPlugin.kt | 25 ++----------- packages/health/example/lib/main.dart | 14 +++----- .../ios/Classes/SwiftHealthPlugin.swift | 3 -- packages/health/lib/src/health_plugin.dart | 36 ++++++------------- 5 files changed, 18 insertions(+), 67 deletions(-) diff --git a/packages/health/README.md b/packages/health/README.md index 9fc85e90f..afd9046e5 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -321,22 +321,17 @@ points = health.removeDuplicates(points); In order to retrieve a single record, it is required to provide `String uuid` and `HealthDataType type`. -**Android** doesn't expose an API to read single raw data by UUID. Instead, It is filtered by `HealthDataType type` and 5 minutes time range from `DateTime startTime` by default, then match and return a single record by UUID natively - -**iOS** has an ability to get a single record by providing UUID and `type`, so both `startTime` and `endTime` are optional. - Please see example below: ```dart HealthDataPoint? healthPoint = await health.getHealthDataByUUID( uuid: 'd7decd36-a26b-45a0-aa02-91484f8a17ca', type: HealthDataType.STEPS, - startTime: activity.startTime, ); ``` ``` 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`, `type` and `startTime` are coming from your database. +> Assuming that the `uuid` and `type` are coming from your database. ## Data Types 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 157c32d7c..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 @@ -909,8 +909,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : private fun getDataByUUID(call: MethodCall, result: Result) { val arguments = call.arguments as? HashMap<*, *> - val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) - val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) val dataType = (arguments?.get("dataTypeKey") as? String)!! val uuid = (arguments?.get("uuid") as? String)!! var healthPoint = mapOf() @@ -925,31 +923,14 @@ class HealthPlugin(private var channel: MethodChannel? = null) : scope.launch { try { - // Set up the initial request to read health records with specified - // parameters - var request = - ReadRecordsRequest( - recordType = classType, - // Define the maximum amount of data - // that HealthConnect can return - // in a single request - timeRangeFilter = - TimeRangeFilter.between( - startTime, - endTime - ), - ) - Log.i( - "FLUTTER_HEALTH", - "Getting $uuid between $startTime and $endTime" - ) + Log.i("FLUTTER_HEALTH", "Getting $uuid with $classType") // Execute the request - val response = healthConnectClient.readRecords(request) + val response = healthConnectClient.readRecord(classType, uuid) // Find the record with the matching UUID - val matchingRecord = response.records.firstOrNull { it.metadata.id == uuid } + val matchingRecord = response.record if (matchingRecord != null) { // Workout needs distance and total calories burned too diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index d6dcff05e..142c4acb5 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -197,26 +197,23 @@ class HealthAppState extends State { }); } - /// Fetch single data point by UUID. + /// Fetch single data point by UUID and type. Future fetchDataByUUID({ required String uuid, - required HealthDataType type, - DateTime? startTime, + HealthDataType? type, }) async { try { // fetch health data HealthDataPoint? healthPoint = await health.getHealthDataByUUID( uuid: uuid, type: type, - startTime: startTime, ); if (healthPoint != null) { // save all the new data points (only the first 100) debugPrint('Fetching health data with ${healthPoint.toString()}'); + openDetailBottomSheet(healthPoint); } - - openDetailBottomSheet(healthPoint); } catch (error) { debugPrint("Exception in getHealthDataByUUID: $error"); } @@ -792,7 +789,6 @@ class HealthAppState extends State { fetchDataByUUID( uuid: p.uuid, type: p.type, - startTime: p.dateFrom, ); }, ); @@ -808,7 +804,6 @@ class HealthAppState extends State { fetchDataByUUID( uuid: p.uuid, type: p.type, - startTime: p.dateFrom, ); }, ); @@ -824,7 +819,6 @@ class HealthAppState extends State { fetchDataByUUID( uuid: p.uuid, type: p.type, - startTime: p.dateFrom, ); }, ); @@ -837,9 +831,9 @@ class HealthAppState extends State { fetchDataByUUID( uuid: p.uuid, type: p.type, - startTime: p.dateFrom, ); }, + onLongPress: () => openDetailBottomSheet(p), ); }); diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 9eb583490..60665c169 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -1155,9 +1155,6 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { throw PluginError(message: "Invalid Arguments - UUID or DataTypeKey invalid") } - - let startTime = arguments["startTime"] as? NSNumber ?? 0 - let endTime = arguments["endTime"] as? NSNumber ?? 0 let dataUnitKey = arguments["dataUnitKey"] as? String let recordingMethodsToFilter = (arguments["recordingMethodsToFilter"] as? [Int]) ?? [] let includeManualEntry = !recordingMethodsToFilter.contains(RecordingMethod.manual.rawValue) diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 9fe63a2e5..f52f3276d 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -480,7 +480,6 @@ class Health { final healthPoint = await getHealthDataByUUID( uuid: uuid, type: type, - startTime: startTime, ); return healthPoint; @@ -982,24 +981,21 @@ class Health { return removeDuplicates(dataPoints); } - /// Fetch a health data points based on UUID and type. + /// Fetch a `HealthDataPoint` by `uuid` and `type`. Returns `null` if no matching record. /// - /// - `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) - /// - `startTime`: Start time of health data point. default is `DateTime.now()` - /// - `endTime`: End time of health data point. default is 5 minutes from `startTime` - /// - /// Assuming all above data are coming from your database. + /// 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) /// - /// **Android** has no API to read a single raw data by UUID. - /// Instead, it is filtered by `type` and 5 minutes time range from `startTime` by default, - /// then match and return a single data by UUID natively + /// Assuming above data are coming from your database. Future getHealthDataByUUID({ required String uuid, required HealthDataType type, - DateTime? startTime, - DateTime? endTime, }) async { + if (uuid.isEmpty) { + throw HealthException(type, 'UUID is empty!'); + } + await _checkIfHealthConnectAvailableOnAndroid(); // Ask for device ID only once @@ -1015,8 +1011,6 @@ class Health { final result = await _dataQueryByUUID( uuid, type, - startTime ?? DateTime.now(), - endTime, ); return result; @@ -1165,24 +1159,15 @@ class Health { } } - /// Fetches single data point by UUID from Android/iOS native code. - /// - /// `startTime` and `endTime` will be used for Android-only + /// Fetches single data point by `uuid` and `type` from Android/iOS native code. Future _dataQueryByUUID( String uuid, HealthDataType dataType, - DateTime startTime, - DateTime? endTime, ) async { - // Add 5 minutes from startTime to limit time range for performance purpose - final recordEndTime = endTime ?? startTime.add(Duration(minutes: 5)); - final args = { 'dataTypeKey': dataType.name, 'dataUnitKey': dataTypeToUnit[dataType]!.name, 'uuid': uuid, - 'startTime': startTime.millisecondsSinceEpoch, - 'endTime': recordEndTime.millisecondsSinceEpoch, }; final fetchedDataPoint = await _channel.invokeMethod('getDataByUUID', args); @@ -1364,7 +1349,6 @@ class Health { final healthPoint = await getHealthDataByUUID( uuid: uuid, type: HealthDataType.WORKOUT, - startTime: start, ); return healthPoint; From 4419b20f6d2786edb76ff3d212245179dfcf8c0b Mon Sep 17 00:00:00 2001 From: Agil Setiawan Date: Thu, 20 Mar 2025 08:59:49 +0700 Subject: [PATCH 8/9] Fix fetchDataByUUID example HealthDataType parameter --- packages/health/example/lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 142c4acb5..e1c1305c8 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -200,7 +200,7 @@ class HealthAppState extends State { /// Fetch single data point by UUID and type. Future fetchDataByUUID({ required String uuid, - HealthDataType? type, + required HealthDataType type, }) async { try { // fetch health data From 6f0bd6c114811844f83db7ed6a337b81ef0ed6d6 Mon Sep 17 00:00:00 2001 From: Agil Setiawan Date: Mon, 21 Apr 2025 14:13:23 +0700 Subject: [PATCH 9/9] Add null safe on `write` if permission not allowed --- packages/health/lib/src/health_plugin.dart | 38 ++++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index f52f3276d..95f3b98fc 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -477,12 +477,20 @@ class Health { String uuid = '${await _channel.invokeMethod('writeData', args)}'; - final healthPoint = await getHealthDataByUUID( - uuid: uuid, - type: type, - ); + if (uuid.isEmpty) { + return null; + } - return healthPoint; + 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. @@ -1346,12 +1354,22 @@ class Health { String uuid = '${await _channel.invokeMethod('writeWorkoutData', args)}'; - final healthPoint = await getHealthDataByUUID( - uuid: uuid, - type: HealthDataType.WORKOUT, - ); + debugPrint('created uuid: $uuid'); - return healthPoint; + 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