diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index eda60a7cf..0d464dcb2 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,8 +1,18 @@ +## 10.2.0 + +* Using named parameters in most methods for consistency. +* Added a `HealthPlatformType` to save which health platform the data originates from (Apple Health, Google Fit, or Google Health Connect). +* Android: Improved support for Google Health Connect + * getHealthConnectSdkStatus, PR [#941](https://github.com/cph-cachet/flutter-plugins/pull/941) + * installHealthConnect, PR [#943](https://github.com/cph-cachet/flutter-plugins/pull/943) + * workout title, PR [#938](https://github.com/cph-cachet/flutter-plugins/pull/938) +* iOS: Add support for saving blood pressure as a correlation, PR [#919](https://github.com/cph-cachet/flutter-plugins/pull/919) + ## 10.1.1 -* fix of error in `WorkoutSummary` JSON serialization. -* fix of [#934](https://github.com/cph-cachet/flutter-plugins/issues/934) -* empty value check for calories nutrition, PR [#926](https://github.com/cph-cachet/flutter-plugins/pull/926) +* Fix of error in `WorkoutSummary` JSON serialization. +* Fix of [#934](https://github.com/cph-cachet/flutter-plugins/issues/934) +* Empty value check for calories nutrition, PR [#926](https://github.com/cph-cachet/flutter-plugins/pull/926) ## 10.0.0 diff --git a/packages/health/README.md b/packages/health/README.md index 9a6ebc06c..30f87c6ca 100644 --- a/packages/health/README.md +++ b/packages/health/README.md @@ -234,17 +234,35 @@ HealthDataType type; HealthDataUnit unit; DateTime dateFrom; DateTime dateTo; -PlatformType platform; -String uuid, deviceId; +HealthPlatformType sourcePlatform; +String sourceDeviceId; String sourceId; String sourceName; bool isManualEntry; WorkoutSummary? workoutSummary; ``` -where a [HealthValue](https://pub.dev/documentation/health/latest/health/HealthValue-class.html) can be any type of `AudiogramHealthValue`, `ElectrocardiogramHealthValue`, `ElectrocardiogramVoltageValue`, `NumericHealthValue`, `NutritionHealthValue`, or `WorkoutHealthValue`. - -A `HealthDataPoint` object can be serialized to and from JSON using the `toJson()` and `fromJson()` methods. JSON serialization is using camel_case notation. +where a [`HealthValue`](https://pub.dev/documentation/health/latest/health/HealthValue-class.html) can be any type of `AudiogramHealthValue`, `ElectrocardiogramHealthValue`, `ElectrocardiogramVoltageValue`, `NumericHealthValue`, `NutritionHealthValue`, or `WorkoutHealthValue`. + +A `HealthDataPoint` object can be serialized to and from JSON using the `toJson()` and `fromJson()` methods. JSON serialization is using camel_case notation. Null values are not serialized. For example; + +```json +{ + "value": { + "__type": "NumericHealthValue", + "numeric_value": 141.0 + }, + "type": "STEPS", + "unit": "COUNT", + "date_from": "2024-04-03T10:06:57.736", + "date_to": "2024-04-03T10:12:51.724", + "source_platform": "appleHealth", + "source_device_id": "F74938B9-C011-4DE4-AA5E-CF41B60B96E7", + "source_id": "com.apple.health.81AE7156-EC05-47E3-AC93-2D6F65C717DF", + "source_name": "iPhone12.bardram.net", + "is_manual_entry": false +} +``` ### Fetch health data 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 7d156be57..323c157c4 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 @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.os.Handler import android.util.Log @@ -2382,7 +2383,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : /** Handle calls from the MethodChannel */ override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { + "installHealthConnect" -> installHealthConnect(call, result) "useHealthConnectIfAvailable" -> useHealthConnectIfAvailable(call, result) + "getHealthConnectSdkStatus" -> getHealthConnectSdkStatus(call, result) "hasPermissions" -> hasPermissions(call, result) "requestAuthorization" -> requestAuthorization(call, result) "revokePermissions" -> revokePermissions(call, result) @@ -2408,15 +2411,13 @@ class HealthPlugin(private var channel: MethodChannel? = null) : binding.addActivityResultListener(this) activity = binding.activity - if (healthConnectAvailable) { - val requestPermissionActivityContract = - PermissionController.createRequestPermissionResultContract() + val requestPermissionActivityContract = + PermissionController.createRequestPermissionResultContract() - healthConnectRequestPermissionsLauncher = - (activity as ComponentActivity).registerForActivityResult( - requestPermissionActivityContract - ) { granted -> onHealthConnectPermissionCallback(granted) } - } + healthConnectRequestPermissionsLauncher = + (activity as ComponentActivity).registerForActivityResult( + requestPermissionActivityContract + ) { granted -> onHealthConnectPermissionCallback(granted) } } override fun onDetachedFromActivityForConfigChanges() { @@ -2444,11 +2445,37 @@ class HealthPlugin(private var channel: MethodChannel? = null) : healthConnectAvailable = healthConnectStatus == HealthConnectClient.SDK_AVAILABLE } + private fun installHealthConnect(call: MethodCall, result: Result) { + val uriString = + "market://details?id=com.google.android.apps.healthdata&url=healthconnect%3A%2F%2Fonboarding" + context!!.startActivity( + Intent(Intent.ACTION_VIEW).apply { + setPackage("com.android.vending") + data = Uri.parse(uriString) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("overlay", true) + putExtra("callerId", context!!.packageName) + } + ) + result.success(null) + } + fun useHealthConnectIfAvailable(call: MethodCall, result: Result) { useHealthConnectIfAvailable = true result.success(null) } + private fun getHealthConnectSdkStatus(call: MethodCall, result: Result) { + checkAvailability() + if (healthConnectAvailable) { + healthConnectClient = + HealthConnectClient.getOrCreate( + context!! + ) + } + result.success(healthConnectStatus) + } + private fun hasPermissionsHC(call: MethodCall, result: Result) { val args = call.arguments as HashMap<*, *> val types = (args["types"] as? ArrayList<*>)?.filterIsInstance()!! @@ -3765,6 +3792,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : return } val workoutType = workoutTypeMapHealthConnect[type]!! + val title = call.argument("title") ?: type scope.launch { try { @@ -3776,7 +3804,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : endTime = endTime, endZoneOffset = null, exerciseType = workoutType, - title = type, + title = title, ), ) if (totalDistance != null) { diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 4da7b0c7c..84e93cf07 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:health/health.dart'; @@ -6,10 +7,6 @@ import 'package:health_example/util.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:carp_serializable/carp_serializable.dart'; -// /// A connivent function to convert a Dart object into a formatted JSON string. -// String toJsonString(Object? object) => -// const JsonEncoder.withIndent(' ').convert(object); - void main() => runApp(HealthApp()); class HealthApp extends StatefulWidget { @@ -29,6 +26,7 @@ enum AppState { DATA_NOT_ADDED, DATA_NOT_DELETED, STEPS_READY, + HEALTH_CONNECT_STATUS, } class _HealthAppState extends State { @@ -36,11 +34,12 @@ class _HealthAppState extends State { AppState _state = AppState.DATA_NOT_FETCHED; int _nofSteps = 0; - // Define the types to get. - - // Use the entire list on e.g. Android. - static final types = dataTypesIOS; - // static final types = dataTypesAndroid; + // All types available depending on platform (iOS ot Android). + List get types => (Platform.isAndroid) + ? dataTypesAndroid + : (Platform.isIOS) + ? dataTypesIOS + : []; // // Or specify specific types // static final types = [ @@ -57,10 +56,12 @@ class _HealthAppState extends State { // Set up corresponding permissions // READ only - final permissions = types.map((e) => HealthDataAccess.READ).toList(); + List get permissions => + types.map((e) => HealthDataAccess.READ).toList(); // Or both READ and WRITE - // final permissions = types.map((e) => HealthDataAccess.READ_WRITE).toList(); + // List get permissions => + // types.map((e) => HealthDataAccess.READ_WRITE).toList(); void initState() { // configure the health plugin before use. @@ -69,6 +70,11 @@ class _HealthAppState extends State { super.initState(); } + /// Install Google Health Connect on this phone. + Future installHealthConnect() async { + await Health().installHealthConnect(); + } + /// Authorize, i.e. get permissions to access relevant health data. Future authorize() async { // If we are trying to read Step Count, Workout, Sleep or other data that requires @@ -102,6 +108,18 @@ class _HealthAppState extends State { (authorized) ? AppState.AUTHORIZED : AppState.AUTH_NOT_GRANTED); } + /// Gets the Health Connect status on Android. + Future getHealthConnectSdkStatus() async { + assert(Platform.isAndroid, "This is only available on Android"); + + final status = await Health().getHealthConnectSdkStatus(); + + setState(() { + _contentHealthConnectStatus = Text('Health Connect Status: $status'); + _state = AppState.HEALTH_CONNECT_STATUS; + }); + } + /// Fetch data points from the health plugin and show them in the app. Future fetchData() async { setState(() => _state = AppState.FETCHING_DATA); @@ -113,20 +131,23 @@ class _HealthAppState extends State { // Clear old data points _healthDataList.clear(); - try { - // fetch health data - List healthData = - await Health().getHealthDataFromTypes(yesterday, now, types); + // try { + // fetch health data + List healthData = await Health().getHealthDataFromTypes( + types: types, + startTime: yesterday, + endTime: now, + ); - debugPrint('Total number of data points: ${healthData.length}. ' - '${healthData.length > 100 ? 'Only showing the first 100.' : ''}'); + debugPrint('Total number of data points: ${healthData.length}. ' + '${healthData.length > 100 ? 'Only showing the first 100.' : ''}'); - // save all the new data points (only the first 100) - _healthDataList.addAll( - (healthData.length < 100) ? healthData : healthData.sublist(0, 100)); - } catch (error) { - debugPrint("Exception in getHealthDataFromTypes: $error"); - } + // save all the new data points (only the first 100) + _healthDataList.addAll( + (healthData.length < 100) ? healthData : healthData.sublist(0, 100)); + // } catch (error) { + // debugPrint("Exception in getHealthDataFromTypes: $error"); + // } // filter out duplicates _healthDataList = Health().removeDuplicates(_healthDataList); @@ -153,46 +174,102 @@ class _HealthAppState extends State { bool success = true; // misc. health data examples using the writeHealthData() method - success &= await Health() - .writeHealthData(1.925, HealthDataType.HEIGHT, earlier, now); - success &= - await Health().writeHealthData(90, HealthDataType.WEIGHT, now, now); - success &= await Health() - .writeHealthData(90, HealthDataType.HEART_RATE, earlier, now); - success &= - await Health().writeHealthData(90, HealthDataType.STEPS, earlier, now); success &= await Health().writeHealthData( - 200, HealthDataType.ACTIVE_ENERGY_BURNED, earlier, now); - success &= await Health() - .writeHealthData(70, HealthDataType.HEART_RATE, earlier, now); - success &= await Health() - .writeHealthData(37, HealthDataType.BODY_TEMPERATURE, earlier, now); - success &= await Health().writeBloodOxygen(98, earlier, now, flowRate: 1.0); - success &= await Health() - .writeHealthData(105, HealthDataType.BLOOD_GLUCOSE, earlier, now); - success &= - await Health().writeHealthData(1.8, HealthDataType.WATER, earlier, now); + value: 1.925, + type: HealthDataType.HEIGHT, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 90, type: HealthDataType.WEIGHT, startTime: now); + success &= await Health().writeHealthData( + value: 90, + type: HealthDataType.HEART_RATE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 90, + type: HealthDataType.STEPS, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 200, + type: HealthDataType.ACTIVE_ENERGY_BURNED, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 70, + type: HealthDataType.HEART_RATE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 37, + type: HealthDataType.BODY_TEMPERATURE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 105, + type: HealthDataType.BLOOD_GLUCOSE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 1.8, + type: HealthDataType.WATER, + startTime: earlier, + endTime: now); // different types of sleep - success &= await Health() - .writeHealthData(0.0, HealthDataType.SLEEP_REM, earlier, now); - success &= await Health() - .writeHealthData(0.0, HealthDataType.SLEEP_ASLEEP, earlier, now); - success &= await Health() - .writeHealthData(0.0, HealthDataType.SLEEP_AWAKE, earlier, now); - success &= await Health() - .writeHealthData(0.0, HealthDataType.SLEEP_DEEP, earlier, now); + success &= await Health().writeHealthData( + value: 0.0, + type: HealthDataType.SLEEP_REM, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 0.0, + type: HealthDataType.SLEEP_ASLEEP, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 0.0, + type: HealthDataType.SLEEP_AWAKE, + startTime: earlier, + endTime: now); + success &= await Health().writeHealthData( + value: 0.0, + type: HealthDataType.SLEEP_DEEP, + startTime: earlier, + endTime: now); // specialized write methods + success &= await Health().writeBloodOxygen( + saturation: 98, + startTime: earlier, + endTime: now, + flowRate: 1.0, + ); success &= await Health().writeWorkoutData( - HealthWorkoutActivityType.AMERICAN_FOOTBALL, - now.subtract(Duration(minutes: 15)), - now, - totalDistance: 2430, - totalEnergyBurned: 400); - success &= await Health().writeBloodPressure(90, 80, earlier, now); + activityType: HealthWorkoutActivityType.AMERICAN_FOOTBALL, + title: "Random workout name that shows up in Health Connect", + start: now.subtract(Duration(minutes: 15)), + end: now, + totalDistance: 2430, + totalEnergyBurned: 400, + ); + success &= await Health().writeBloodPressure( + systolic: 90, + diastolic: 80, + startTime: now, + ); success &= await Health().writeMeal( - earlier, now, 1000, 50, 25, 50, "Banana", 0.002, MealType.SNACK); + mealType: MealType.SNACK, + startTime: earlier, + endTime: now, + caloriesConsumed: 1000, + carbohydrates: 50, + protein: 25, + fatTotal: 50, + name: "Banana", + caffeine: 0.002, + ); // Store an Audiogram - only available on iOS // const frequencies = [125.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0]; @@ -222,7 +299,11 @@ class _HealthAppState extends State { bool success = true; for (HealthDataType type in types) { - success &= await Health().delete(type, earlier, now); + success &= await Health().delete( + type: type, + startTime: earlier, + endTime: now, + ); } setState(() { @@ -290,11 +371,19 @@ class _HealthAppState extends State { children: [ TextButton( onPressed: authorize, - child: - Text("Auth", style: TextStyle(color: Colors.white)), + child: Text("Authenticate", + style: TextStyle(color: Colors.white)), style: ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.blue))), + if (Platform.isAndroid) + TextButton( + onPressed: getHealthConnectSdkStatus, + child: Text("Check Health Connect Status", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), TextButton( onPressed: fetchData, child: Text("Fetch Data", @@ -330,6 +419,14 @@ class _HealthAppState extends State { style: ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.blue))), + if (Platform.isAndroid) + TextButton( + onPressed: installHealthConnect, + child: Text("Install Health Connect", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), ], ), Divider(thickness: 3), @@ -412,6 +509,9 @@ class _HealthAppState extends State { mainAxisAlignment: MainAxisAlignment.center, ); + Widget _contentHealthConnectStatus = const Text( + 'No status, click getHealthConnectSdkStatus to get the status.'); + Widget _dataAdded = const Text('Data points inserted successfully.'); Widget _dataDeleted = const Text('Data points deleted successfully.'); @@ -435,5 +535,6 @@ class _HealthAppState extends State { AppState.DATA_NOT_ADDED => _dataNotAdded, AppState.DATA_NOT_DELETED => _dataNotDeleted, AppState.STEPS_READY => _stepsFetched, + AppState.HEALTH_CONNECT_STATUS => _contentHealthConnectStatus, }; } diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index a85a51197..efc83f371 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -426,9 +426,12 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { type: HKSampleType.quantityType(forIdentifier: .bloodPressureDiastolic)!, quantity: HKQuantity(unit: HKUnit.millimeterOfMercury(), doubleValue: diastolic), start: dateFrom, end: dateTo) + let bpCorrelationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure)! + let bpCorrelation = Set(arrayLiteral: systolic_sample, diastolic_sample) + let blood_pressure_sample = HKCorrelation(type: bpCorrelationType , start: dateFrom, end: dateTo, objects: bpCorrelation) HKHealthStore().save( - [systolic_sample, diastolic_sample], + [blood_pressure_sample], withCompletion: { (success, error) in if let err = error { print("Error Saving Blood Pressure Sample: \(err.localizedDescription)") diff --git a/packages/health/lib/health.g.dart b/packages/health/lib/health.g.dart index 1e52a3762..8b23ccc0e 100644 --- a/packages/health/lib/health.g.dart +++ b/packages/health/lib/health.g.dart @@ -13,8 +13,9 @@ HealthDataPoint _$HealthDataPointFromJson(Map json) => unit: $enumDecode(_$HealthDataUnitEnumMap, json['unit']), dateFrom: DateTime.parse(json['date_from'] as String), dateTo: DateTime.parse(json['date_to'] as String), - platform: $enumDecode(_$PlatformTypeEnumMap, json['platform']), - deviceId: json['device_id'] as String, + sourcePlatform: + $enumDecode(_$HealthPlatformTypeEnumMap, json['source_platform']), + sourceDeviceId: json['source_device_id'] as String, sourceId: json['source_id'] as String, sourceName: json['source_name'] as String, isManualEntry: json['is_manual_entry'] as bool? ?? false, @@ -31,8 +32,8 @@ Map _$HealthDataPointToJson(HealthDataPoint instance) { 'unit': _$HealthDataUnitEnumMap[instance.unit]!, 'date_from': instance.dateFrom.toIso8601String(), 'date_to': instance.dateTo.toIso8601String(), - 'platform': _$PlatformTypeEnumMap[instance.platform]!, - 'device_id': instance.deviceId, + 'source_platform': _$HealthPlatformTypeEnumMap[instance.sourcePlatform]!, + 'source_device_id': instance.sourceDeviceId, 'source_id': instance.sourceId, 'source_name': instance.sourceName, 'is_manual_entry': instance.isManualEntry, @@ -163,9 +164,10 @@ const _$HealthDataUnitEnumMap = { HealthDataUnit.NO_UNIT: 'NO_UNIT', }; -const _$PlatformTypeEnumMap = { - PlatformType.IOS: 'IOS', - PlatformType.ANDROID: 'ANDROID', +const _$HealthPlatformTypeEnumMap = { + HealthPlatformType.appleHealth: 'appleHealth', + HealthPlatformType.googleFit: 'googleFit', + HealthPlatformType.googleHealthConnect: 'googleHealthConnect', }; HealthValue _$HealthValueFromJson(Map json) => diff --git a/packages/health/lib/src/functions.dart b/packages/health/lib/src/functions.dart index 7d1d8a80e..cc17b988c 100644 --- a/packages/health/lib/src/functions.dart +++ b/packages/health/lib/src/functions.dart @@ -16,5 +16,31 @@ class HealthException implements Exception { "Error requesting health data type '$dataType' - cause: $cause"; } -/// A list of supported platforms. -enum PlatformType { IOS, ANDROID } +/// The status of Google Health Connect. +/// +/// **NOTE** - The enum order is arbitrary. If you need the native value, +/// use [nativeValue] and not the index. +/// +/// Reference: +/// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#constants_1 +enum HealthConnectSdkStatus { + /// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#SDK_UNAVAILABLE() + sdkUnavailable(1), + + /// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED() + sdkUnavailableProviderUpdateRequired(2), + + /// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#SDK_AVAILABLE() + sdkAvailable(3); + + const HealthConnectSdkStatus(this.nativeValue); + + /// The native value that matches the value in the Android SDK. + final int nativeValue; + + factory HealthConnectSdkStatus.fromNativeValue(int value) { + return HealthConnectSdkStatus.values.firstWhere( + (e) => e.nativeValue == value, + orElse: () => HealthConnectSdkStatus.sdkUnavailable); + } +} diff --git a/packages/health/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index a734aa690..3a6f65f95 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -1,5 +1,8 @@ part of '../health.dart'; +/// Types of health platforms. +enum HealthPlatformType { appleHealth, googleFit, googleHealthConnect } + /// A [HealthDataPoint] object corresponds to a data point capture from /// Apple HealthKit or Google Fit or Google Health Connect with a [HealthValue] /// as value. @@ -26,11 +29,11 @@ class HealthDataPoint { /// The end of the time interval. DateTime dateTo; - /// The software platform of the data point. - PlatformType platform; + /// The health platform that this data point was fetched. + HealthPlatformType sourcePlatform; /// The id of the device from which the data point was fetched. - String deviceId; + String sourceDeviceId; /// The id of the source from which the data point was fetched. String sourceId; @@ -50,8 +53,8 @@ class HealthDataPoint { required this.unit, required this.dateFrom, required this.dateTo, - required this.platform, - required this.deviceId, + required this.sourcePlatform, + required this.sourceDeviceId, required this.sourceId, required this.sourceName, this.isManualEntry = false, @@ -131,8 +134,8 @@ class HealthDataPoint { unit: unit, dateFrom: from, dateTo: to, - platform: Health().platformType, - deviceId: Health().deviceId, + sourcePlatform: Health().platformType, + sourceDeviceId: Health().deviceId, sourceId: sourceId, sourceName: sourceName, isManualEntry: isManualEntry, @@ -147,8 +150,8 @@ class HealthDataPoint { dateFrom: $dateFrom, dateTo: $dateTo, dataType: ${type.name}, - platform: $platform, - deviceId: $deviceId, + platform: $sourcePlatform, + deviceId: $sourceDeviceId, sourceId: $sourceId, sourceName: $sourceName isManualEntry: $isManualEntry @@ -162,13 +165,13 @@ class HealthDataPoint { dateFrom == other.dateFrom && dateTo == other.dateTo && type == other.type && - platform == other.platform && - deviceId == other.deviceId && + sourcePlatform == other.sourcePlatform && + sourceDeviceId == other.sourceDeviceId && sourceId == other.sourceId && sourceName == other.sourceName && isManualEntry == other.isManualEntry; @override - int get hashCode => Object.hash(value, unit, dateFrom, dateTo, type, platform, - deviceId, sourceId, sourceName); + int get hashCode => Object.hash(value, unit, dateFrom, dateTo, type, + sourcePlatform, sourceDeviceId, sourceId, sourceName); } diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index fb8e1b763..cb755553c 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -27,7 +27,7 @@ class Health { String? _deviceId; final _deviceInfo = DeviceInfoPlugin(); - late bool _useHealthConnectIfAvailable; + bool _useHealthConnectIfAvailable = false; Health._() { _registerFromJsonFunctions(); @@ -37,8 +37,11 @@ class Health { factory Health() => _instance; /// The type of platform of this device. - PlatformType get platformType => - Platform.isAndroid ? PlatformType.ANDROID : PlatformType.IOS; + HealthPlatformType get platformType => Platform.isIOS + ? HealthPlatformType.appleHealth + : useHealthConnectIfAvailable + ? HealthPlatformType.googleHealthConnect + : HealthPlatformType.googleFit; /// The id of this device. /// @@ -51,7 +54,7 @@ class Health { /// If [useHealthConnectIfAvailable] is true, Google Health Connect on /// Android will be used. Has no effect on iOS. Future configure({bool useHealthConnectIfAvailable = false}) async { - _deviceId ??= platformType == PlatformType.ANDROID + _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id : (await _deviceInfo.iosInfo).identifierForVendor; @@ -67,10 +70,9 @@ class Health { bool get useHealthConnectIfAvailable => _useHealthConnectIfAvailable; /// Check if a given data type is available on the platform - bool isDataTypeAvailable(HealthDataType dataType) => - platformType == PlatformType.ANDROID - ? dataTypeKeysAndroid.contains(dataType) - : dataTypeKeysIOS.contains(dataType); + bool isDataTypeAvailable(HealthDataType dataType) => Platform.isAndroid + ? dataTypeKeysAndroid.contains(dataType) + : dataTypeKeysIOS.contains(dataType); /// Determines if the health data [types] have been granted with the specified /// access rights [permissions]. @@ -112,7 +114,7 @@ class Health { : permissions.map((permission) => permission.index).toList(); /// On Android, if BMI is requested, then also ask for weight and height - if (platformType == PlatformType.ANDROID) _handleBMI(mTypes, mPermissions); + if (Platform.isAndroid) _handleBMI(mTypes, mPermissions); return await _channel.invokeMethod('hasPermissions', { "types": mTypes.map((type) => type.name).toList(), @@ -127,7 +129,7 @@ class Health { /// Not implemented on iOS as there is no way to programmatically remove access. Future revokePermissions() async { try { - if (platformType == PlatformType.IOS) { + if (Platform.isIOS) { throw UnsupportedError( 'Revoke permissions is not supported on iOS. Please revoke permissions manually in the settings.'); } @@ -138,6 +140,42 @@ class Health { } } + /// Returns the current status of Health Connect availability. + /// + /// See this for more info: + /// https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient#getSdkStatus(android.content.Context,kotlin.String) + /// + /// Android only. + Future getHealthConnectSdkStatus() async { + try { + if (Platform.isIOS) { + throw UnsupportedError('Health Connect is not available on iOS.'); + } + final int status = + (await _channel.invokeMethod('getHealthConnectSdkStatus'))!; + return HealthConnectSdkStatus.fromNativeValue(status); + } catch (e) { + debugPrint('$runtimeType - Exception in getHealthConnectSdkStatus(): $e'); + return null; + } + } + + /// Prompt the user to install the Health Connect app via the installed store + /// (most likely Play Store). + /// + /// Android only. + Future installHealthConnect() async { + try { + if (!Platform.isAndroid) { + throw UnsupportedError( + 'installHealthConnect is only available on Android'); + } + await _channel.invokeMethod('installHealthConnect'); + } catch (e) { + debugPrint('$runtimeType - Exception in installHealthConnect(): $e'); + } + } + /// Disconnect from Google fit. /// /// Not supported on iOS and Google Health Connect, and the method does nothing. @@ -157,7 +195,7 @@ class Health { : permissions.map((permission) => permission.index).toList(); // on Android, if BMI is requested, then also ask for weight and height - if (platformType == PlatformType.ANDROID) _handleBMI(mTypes, mPermissions); + if (Platform.isAndroid) _handleBMI(mTypes, mPermissions); List keys = mTypes.map((dataType) => dataType.name).toList(); @@ -217,7 +255,7 @@ class Health { : permissions.map((permission) => permission.index).toList(); // on Android, if BMI is requested, then also ask for weight and height - if (platformType == PlatformType.ANDROID) _handleBMI(mTypes, mPermissions); + if (Platform.isAndroid) _handleBMI(mTypes, mPermissions); List keys = mTypes.map((e) => e.name).toList(); final bool? isAuthorized = await _channel.invokeMethod( @@ -229,7 +267,7 @@ class Health { void _handleBMI(List mTypes, List mPermissions) { final index = mTypes.indexOf(HealthDataType.BODY_MASS_INDEX); - if (index != -1 && platformType == PlatformType.ANDROID) { + if (index != -1 && Platform.isAndroid) { if (!mTypes.contains(HealthDataType.WEIGHT)) { mTypes.add(HealthDataType.WEIGHT); mPermissions.add(mPermissions[index]); @@ -276,8 +314,8 @@ class Health { unit: unit, dateFrom: weights[i].dateFrom, dateTo: weights[i].dateTo, - platform: platformType, - deviceId: _deviceId!, + sourcePlatform: platformType, + sourceDeviceId: _deviceId!, sourceId: '', sourceName: '', isManualEntry: !includeManualEntry, @@ -293,28 +331,30 @@ class Health { /// Returns true if successful, false otherwise. /// /// Parameters: - /// * [value] - the health data's value in double - /// * [type] - the value's HealthDataType - /// * [startTime] - the start time when this [value] is measured. - /// + It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when this [value] is measured. - /// + It must be equal to or later than [startTime]. - /// + Simply set [endTime] equal to [startTime] if the [value] is measured only at a specific point in time. - /// * [unit] - (iOS ONLY) the unit the health data is measured in. + /// * [value] - the health data's value in double + /// * [unit] - **iOS ONLY** the unit the health data is measured in. + /// * [type] - the value's HealthDataType + /// * [startTime] - the start time when this [value] is measured. + /// It must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when this [value] is measured. + /// It must be equal to or later than [startTime]. + /// Simply set [endTime] equal to [startTime] if the [value] is measured + /// only at a specific point in time (default). /// /// Values for Sleep and Headache are ignored and will be automatically assigned /// the default value. - Future writeHealthData( - double value, - HealthDataType type, - DateTime startTime, - DateTime endTime, { + Future writeHealthData({ + required double value, HealthDataUnit? unit, + required HealthDataType type, + required DateTime startTime, + DateTime? endTime, }) async { if (type == HealthDataType.WORKOUT) { throw ArgumentError( "Adding workouts should be done using the writeWorkoutData method."); } + endTime ??= startTime; if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); } @@ -324,7 +364,7 @@ class Health { HealthDataType.IRREGULAR_HEART_RATE_EVENT, HealthDataType.ELECTROCARDIOGRAM, }.contains(type) && - platformType == PlatformType.IOS) { + Platform.isIOS) { throw ArgumentError( "$type - iOS does not support writing this data type in HealthKit"); } @@ -371,11 +411,12 @@ class Health { /// Must be equal to or earlier than [endTime]. /// * [endTime] - the end time when this [value] is measured. /// Must be equal to or later than [startTime]. - Future delete( - HealthDataType type, - DateTime startTime, - DateTime endTime, - ) async { + Future delete({ + required HealthDataType type, + required DateTime startTime, + DateTime? endTime, + }) async { + endTime ??= startTime; if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); } @@ -401,13 +442,14 @@ class Health { /// * [endTime] - the end time when this [value] is measured. /// Must be equal to or later than [startTime]. /// Simply set [endTime] equal to [startTime] if the blood pressure is measured - /// only at a specific point in time. - Future writeBloodPressure( - int systolic, - int diastolic, - DateTime startTime, - DateTime endTime, - ) async { + /// only at a specific point in time. If omitted, [endTime] is set to [startTime]. + Future writeBloodPressure({ + required int systolic, + required int diastolic, + required DateTime startTime, + DateTime? endTime, + }) async { + endTime ??= startTime; if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); } @@ -429,27 +471,31 @@ class Health { /// * [saturation] - the saturation of the blood oxygen in percentage /// * [flowRate] - optional supplemental oxygen flow rate, only supported on /// Google Fit (default 0.0) - /// * [startTime] - the start time when this [value] is measured. + /// * [startTime] - the start time when this [saturation] is measured. /// Must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when this [value] is measured. + /// * [endTime] - the end time when this [saturation] is measured. /// Must be equal to or later than [startTime]. /// Simply set [endTime] equal to [startTime] if the blood oxygen saturation - /// is measured only at a specific point in time. - Future writeBloodOxygen( - double saturation, - DateTime startTime, - DateTime endTime, { + /// is measured only at a specific point in time (default). + Future writeBloodOxygen({ + required double saturation, double flowRate = 0.0, + required DateTime startTime, + DateTime? endTime, }) async { + endTime ??= startTime; if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); } bool? success; - if (platformType == PlatformType.IOS) { + if (Platform.isIOS) { success = await writeHealthData( - saturation, HealthDataType.BLOOD_OXYGEN, startTime, endTime); - } else if (platformType == PlatformType.ANDROID) { + value: saturation, + type: HealthDataType.BLOOD_OXYGEN, + startTime: startTime, + endTime: endTime); + } else if (Platform.isAndroid) { Map args = { 'value': saturation, 'flowRate': flowRate, @@ -462,30 +508,32 @@ class Health { return success ?? false; } - /// Saves meal record into Apple Health or Google Fit. + /// Saves meal record into Apple Health or Google Fit / Health Connect. /// /// Returns true if successful, false otherwise. /// /// Parameters: - /// * [startTime] - the start time when the meal was consumed. - /// + It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when the meal was consumed. - /// + It must be equal to or later than [startTime]. - /// * [caloriesConsumed] - total calories consumed with this meal. - /// * [carbohydrates] - optional carbohydrates information. - /// * [protein] - optional protein information. - /// * [fatTotal] - optional total fat information. - /// * [name] - optional name information about this meal. - Future writeMeal( - DateTime startTime, - DateTime endTime, - double? caloriesConsumed, - double? carbohydrates, - double? protein, - double? fatTotal, - String? name, - double? caffeine, - MealType mealType) async { + /// * [mealType] - the type of meal. + /// * [startTime] - the start time when the meal was consumed. + /// It must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when the meal was consumed. + /// It must be equal to or later than [startTime]. + /// * [caloriesConsumed] - total calories consumed with this meal. + /// * [carbohydrates] - optional carbohydrates information. + /// * [protein] - optional protein information. + /// * [fatTotal] - optional total fat information. + /// * [name] - optional name information about this meal. + Future writeMeal({ + required MealType mealType, + required DateTime startTime, + required DateTime endTime, + double? caloriesConsumed, + double? carbohydrates, + double? protein, + double? fatTotal, + String? name, + double? caffeine, + }) async { if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); } @@ -510,22 +558,25 @@ class Health { /// Returns true if successful, false otherwise. /// /// Parameters: - /// * [frequencies] - array of frequencies of the test - /// * [leftEarSensitivities] threshold in decibel for the left ear - /// * [rightEarSensitivities] threshold in decibel for the left ear - /// * [startTime] - the start time when the audiogram is measured. - /// It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when the audiogram is measured. - /// It must be equal to or later than [startTime]. - /// Simply set [endTime] equal to [startTime] if the audiogram is measured only at a specific point in time. - /// * [metadata] - optional map of keys, both HKMetadataKeyExternalUUID and HKMetadataKeyDeviceName are required - Future writeAudiogram( - List frequencies, - List leftEarSensitivities, - List rightEarSensitivities, - DateTime startTime, - DateTime endTime, - {Map? metadata}) async { + /// * [frequencies] - array of frequencies of the test + /// * [leftEarSensitivities] threshold in decibel for the left ear + /// * [rightEarSensitivities] threshold in decibel for the left ear + /// * [startTime] - the start time when the audiogram is measured. + /// It must be equal to or earlier than [endTime]. + /// * [endTime] - the end time when the audiogram is measured. + /// It must be equal to or later than [startTime]. + /// Simply set [endTime] equal to [startTime] if the audiogram is measured + /// only at a specific point in time (default). + /// * [metadata] - optional map of keys, both HKMetadataKeyExternalUUID + /// and HKMetadataKeyDeviceName are required + Future writeAudiogram({ + required List frequencies, + required List leftEarSensitivities, + required List rightEarSensitivities, + required DateTime startTime, + DateTime? endTime, + Map? metadata, + }) async { if (frequencies.isEmpty || leftEarSensitivities.isEmpty || rightEarSensitivities.isEmpty) { @@ -537,10 +588,11 @@ class Health { throw ArgumentError( "frequencies, leftEarSensitivities and rightEarSensitivities need to be of the same length"); } + endTime ??= startTime; if (startTime.isAfter(endTime)) { throw ArgumentError("startTime must be equal or earlier than endTime"); } - if (platformType == PlatformType.ANDROID) { + if (Platform.isAndroid) { throw UnsupportedError("writeAudiogram is not supported on Android"); } @@ -557,10 +609,10 @@ class Health { } /// Fetch a list of health data points based on [types]. - Future> getHealthDataFromTypes( - DateTime startTime, - DateTime endTime, - List types, { + Future> getHealthDataFromTypes({ + required List types, + required DateTime startTime, + required DateTime endTime, bool includeManualEntry = true, }) async { List dataPoints = []; @@ -581,11 +633,11 @@ class Health { /// Fetch a list of health data points based on [types]. Future> getHealthIntervalDataFromTypes( - DateTime startDate, - DateTime endDate, - List types, - int interval, - {bool includeManualEntry = true}) async { + {required DateTime startDate, + required DateTime endDate, + required List types, + required int interval, + bool includeManualEntry = true}) async { List dataPoints = []; for (var type in types) { @@ -598,10 +650,10 @@ class Health { } /// Fetch a list of health data points based on [types]. - Future> getHealthAggregateDataFromTypes( - DateTime startDate, - DateTime endDate, - List types, { + Future> getHealthAggregateDataFromTypes({ + required List types, + required DateTime startDate, + required DateTime endDate, int activitySegmentDuration = 1, bool includeManualEntry = true, }) async { @@ -622,7 +674,7 @@ class Health { bool includeManualEntry, ) async { // Ask for device ID only once - _deviceId ??= platformType == PlatformType.ANDROID + _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id : (await _deviceInfo.iosInfo).identifierForVendor; @@ -633,8 +685,7 @@ class Health { } // If BodyMassIndex is requested on Android, calculate this manually - if (dataType == HealthDataType.BODY_MASS_INDEX && - platformType == PlatformType.ANDROID) { + if (dataType == HealthDataType.BODY_MASS_INDEX && Platform.isAndroid) { return _computeAndroidBMI(startTime, endTime, includeManualEntry); } return await _dataQuery(startTime, endTime, dataType, includeManualEntry); @@ -648,7 +699,7 @@ class Health { int interval, bool includeManualEntry) async { // Ask for device ID only once - _deviceId ??= platformType == PlatformType.ANDROID + _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id : (await _deviceInfo.iosInfo).identifierForVendor; @@ -670,7 +721,7 @@ class Health { int activitySegmentDuration, bool includeManualEntry) async { // Ask for device ID only once - _deviceId ??= platformType == PlatformType.ANDROID + _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id : (await _deviceInfo.iosInfo).identifierForVendor; @@ -822,33 +873,37 @@ class Health { "HealthDataType was not aligned correctly - please report bug at https://github.com/cph-cachet/flutter-plugins/issues"), }; - /// Write workout data to Apple Health + /// Write workout data to Apple Health or Google Fit or Google Health Connect. /// - /// Returns true if successfully added workout data. + /// Returns true if the workout data was successfully added. /// /// Parameters: - /// - [activityType] The type of activity performed - /// - [start] The start time of the workout - /// - [end] The end time of the workout - /// - [totalEnergyBurned] The total energy burned during the workout - /// - [totalEnergyBurnedUnit] The UNIT used to measure [totalEnergyBurned] *ONLY FOR IOS* Default value is KILOCALORIE. - /// - [totalDistance] The total distance traveled during the workout - /// - [totalDistanceUnit] The UNIT used to measure [totalDistance] *ONLY FOR IOS* Default value is METER. - Future writeWorkoutData( - HealthWorkoutActivityType activityType, - DateTime start, - DateTime end, { + /// - [activityType] The type of activity performed. + /// - [start] The start time of the workout. + /// - [end] The end time of the workout. + /// - [totalEnergyBurned] The total energy burned during the workout. + /// - [totalEnergyBurnedUnit] The UNIT used to measure [totalEnergyBurned] + /// *ONLY FOR IOS* Default value is KILOCALORIE. + /// - [totalDistance] The total distance traveled during the workout. + /// - [totalDistanceUnit] The UNIT used to measure [totalDistance] + /// *ONLY FOR IOS* Default value is METER. + /// - [title] The title of the workout. + /// *ONLY FOR HEALTH CONNECT* Default value is the [activityType], e.g. "STRENGTH_TRAINING". + Future writeWorkoutData({ + required HealthWorkoutActivityType activityType, + required DateTime start, + required DateTime end, int? totalEnergyBurned, HealthDataUnit totalEnergyBurnedUnit = HealthDataUnit.KILOCALORIE, int? totalDistance, HealthDataUnit totalDistanceUnit = HealthDataUnit.METER, + String? title, }) async { // Check that value is on the current Platform - if (platformType == PlatformType.IOS && !_isOnIOS(activityType)) { + if (Platform.isIOS && !_isOnIOS(activityType)) { throw HealthException(activityType, "Workout activity type $activityType is not supported on iOS"); - } else if (platformType == PlatformType.ANDROID && - !_isOnAndroid(activityType)) { + } else if (Platform.isAndroid && !_isOnAndroid(activityType)) { throw HealthException(activityType, "Workout activity type $activityType is not supported on Android"); } @@ -860,6 +915,7 @@ class Health { 'totalEnergyBurnedUnit': totalEnergyBurnedUnit.name, 'totalDistance': totalDistance, 'totalDistanceUnit': totalDistanceUnit.name, + 'title': title, }; return await _channel.invokeMethod('writeWorkoutData', args) == true; } diff --git a/packages/health/lib/src/heath_data_types.dart b/packages/health/lib/src/heath_data_types.dart index 37ca9b5cd..dfd35efce 100644 --- a/packages/health/lib/src/heath_data_types.dart +++ b/packages/health/lib/src/heath_data_types.dart @@ -239,10 +239,10 @@ const Map dataTypeToUnit = { HealthDataType.TOTAL_CALORIES_BURNED: HealthDataUnit.KILOCALORIE, }; -const PlatformTypeJsonValue = { - PlatformType.IOS: 'ios', - PlatformType.ANDROID: 'android', -}; +// const PlatformTypeJsonValue = { +// PlatformType.IOS: 'ios', +// PlatformType.ANDROID: 'android', +// }; /// List of all [HealthDataUnit]s. enum HealthDataUnit { diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 9d89e5104..cc118c503 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -1,6 +1,6 @@ name: health description: Wrapper for HealthKit on iOS and Google Fit and Health Connect on Android. -version: 10.1.1 +version: 10.2.0 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: