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/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..ed1679690 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. @@ -579,6 +601,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 +822,79 @@ 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 +950,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/HealthDataReader.swift b/packages/health/ios/Classes/HealthDataReader.swift index 675c64460..8495ac359 100644 --- a/packages/health/ios/Classes/HealthDataReader.swift +++ b/packages/health/ios/Classes/HealthDataReader.swift @@ -316,6 +316,245 @@ class HealthDataReader { 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 as! [String : Any?]) + } + } + + DispatchQueue.main.async { + result(foods.first) + } + } else { + if #available(iOS 14.0, *), let ecgSamples = samples as? [HKElectrocardiogram] { + let dictionaries = ecgSamples.map(self.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) + } + } + } + } + + healthStore.execute(query) + } /// Gets interval health data /// - Parameters: 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/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 90e7a0cd5..d92671d25 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -1004,6 +1004,46 @@ class Health { 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. @@ -1173,6 +1213,35 @@ 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,