From 6bcf4d8d0e1dcf433e5298bf3f3067764608cd29 Mon Sep 17 00:00:00 2001 From: Alireza Hajebrahimi <6937697+iarata@users.noreply.github.com> Date: Mon, 25 Aug 2025 13:04:03 +0200 Subject: [PATCH 1/4] [Health 13.1.1] Late initialization error on android when calling getHealthConnectSdkStatus() Fixes #1250 --- .../cachet/plugins/health/HealthPlugin.kt | 193 ++++++++++-------- 1 file changed, 108 insertions(+), 85 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 2da6af19e..27c94c76d 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 @@ -22,13 +22,13 @@ import io.flutter.plugin.common.PluginRegistry.ActivityResultListener import kotlinx.coroutines.* /** - * Main Flutter plugin class for Health Connect integration. - * Manages plugin lifecycle, method channel communication, permission handling, - * and coordinates between Flutter and Android Health Connect APIs. + * Main Flutter plugin class for Health Connect integration. Manages plugin lifecycle, method + * channel communication, permission handling, and coordinates between Flutter and Android Health + * Connect APIs. */ class HealthPlugin(private var channel: MethodChannel? = null) : - MethodCallHandler, ActivityResultListener, Result, ActivityAware, FlutterPlugin { - + MethodCallHandler, ActivityResultListener, Result, ActivityAware, FlutterPlugin { + private var mResult: Result? = null private var handler: Handler? = null private var activity: Activity? = null @@ -53,33 +53,32 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } /** - * Initializes the plugin when attached to the Flutter engine. - * Sets up method channel, checks Health Connect availability, and initializes helper classes. - * + * Initializes the plugin when attached to the Flutter engine. Sets up method channel, checks + * Health Connect availability, and initializes helper classes. + * * @param flutterPluginBinding Plugin binding providing access to Flutter engine resources */ override fun onAttachedToEngine( - @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding + @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding ) { scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) channel?.setMethodCallHandler(this) context = flutterPluginBinding.applicationContext handler = Handler(context!!.mainLooper) - + checkAvailability() if (healthConnectAvailable) { - healthConnectClient = HealthConnectClient.getOrCreate( - flutterPluginBinding.applicationContext - ) + healthConnectClient = + HealthConnectClient.getOrCreate(flutterPluginBinding.applicationContext) initializeHelpers() } } /** - * Cleans up resources when plugin is detached from Flutter engine. - * Cancels coroutines and nullifies references to prevent memory leaks. - * + * Cleans up resources when plugin is detached from Flutter engine. Cancels coroutines and + * nullifies references to prevent memory leaks. + * * @param binding Plugin binding (unused in cleanup) */ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -97,22 +96,22 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } override fun error( - errorCode: String, - errorMessage: String?, - errorDetails: Any?, + errorCode: String, + errorMessage: String?, + errorDetails: Any?, ) { handler?.post { mResult?.error(errorCode, errorMessage, errorDetails) } } - + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { return false } /** - * Handles method calls from Flutter and routes them to appropriate handler classes. - * Central dispatcher for all Health Connect operations including permissions, - * data reading, writing, and deletion. - * + * Handles method calls from Flutter and routes them to appropriate handler classes. Central + * dispatcher for all Health Connect operations including permissions, data reading, writing, + * and deletion. + * * @param call Method call from Flutter containing method name and arguments * @param result Result callback to return data or status to Flutter */ @@ -120,29 +119,42 @@ class HealthPlugin(private var channel: MethodChannel? = null) : when (call.method) { // SDK and Installation "installHealthConnect" -> installHealthConnect(call, result) - "getHealthConnectSdkStatus" -> dataOperations.getHealthConnectSdkStatus(call, result) - + "getHealthConnectSdkStatus" -> { + checkAvailability() + if (healthConnectAvailable && !(this::dataOperations.isInitialized)) { + healthConnectClient = HealthConnectClient.getOrCreate(context!!) + initializeHelpers() + } + result.success(healthConnectStatus) + } + // Permissions "hasPermissions" -> dataOperations.hasPermissions(call, result) "requestAuthorization" -> requestAuthorization(call, result) "revokePermissions" -> dataOperations.revokePermissions(call, result) - + // History permissions - "isHealthDataHistoryAvailable" -> dataOperations.isHealthDataHistoryAvailable(call, result) - "isHealthDataHistoryAuthorized" -> dataOperations.isHealthDataHistoryAuthorized(call, result) - "requestHealthDataHistoryAuthorization" -> requestHealthDataHistoryAuthorization(call, result) - + "isHealthDataHistoryAvailable" -> + dataOperations.isHealthDataHistoryAvailable(call, result) + "isHealthDataHistoryAuthorized" -> + dataOperations.isHealthDataHistoryAuthorized(call, result) + "requestHealthDataHistoryAuthorization" -> + requestHealthDataHistoryAuthorization(call, result) + // Background permissions - "isHealthDataInBackgroundAvailable" -> dataOperations.isHealthDataInBackgroundAvailable(call, result) - "isHealthDataInBackgroundAuthorized" -> dataOperations.isHealthDataInBackgroundAuthorized(call, result) - "requestHealthDataInBackgroundAuthorization" -> requestHealthDataInBackgroundAuthorization(call, result) - + "isHealthDataInBackgroundAvailable" -> + dataOperations.isHealthDataInBackgroundAvailable(call, result) + "isHealthDataInBackgroundAuthorized" -> + dataOperations.isHealthDataInBackgroundAuthorized(call, result) + "requestHealthDataInBackgroundAuthorization" -> + requestHealthDataInBackgroundAuthorization(call, result) + // Reading data "getData" -> dataReader.getData(call, result) "getIntervalData" -> dataReader.getIntervalData(call, result) "getAggregateData" -> dataReader.getAggregateData(call, result) "getTotalStepsInInterval" -> dataReader.getTotalStepsInInterval(call, result) - + // Writing data "writeData" -> dataWriter.writeData(call, result) "writeWorkoutData" -> dataWriter.writeWorkoutData(call, result) @@ -150,21 +162,20 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "writeBloodOxygen" -> dataWriter.writeBloodOxygen(call, result) "writeMenstruationFlow" -> dataWriter.writeMenstruationFlow(call, result) "writeMeal" -> dataWriter.writeMeal(call, result) - // TODO: Add support for multiple speed for iOS as well + // TODO: Add support for multiple speed for iOS as well // "writeMultipleSpeed" -> dataWriter.writeMultipleSpeedData(call, result) - + // Deleting data "delete" -> dataOperations.deleteData(call, result) "deleteByUUID" -> dataOperations.deleteByUUID(call, result) - else -> result.notImplemented() } } /** - * Called when activity is attached to the plugin. - * Sets up permission request launcher and activity result handling. - * + * Called when activity is attached to the plugin. Sets up permission request launcher and + * activity result handling. + * * @param binding Activity plugin binding providing activity context */ override fun onAttachedToActivity(binding: ActivityPluginBinding) { @@ -175,14 +186,14 @@ class HealthPlugin(private var channel: MethodChannel? = null) : activity = binding.activity val requestPermissionActivityContract = - PermissionController.createRequestPermissionResultContract() + PermissionController.createRequestPermissionResultContract() healthConnectRequestPermissionsLauncher = - (activity as ComponentActivity).registerForActivityResult( - requestPermissionActivityContract - ) { granted -> onHealthConnectPermissionCallback(granted) } + (activity as ComponentActivity).registerForActivityResult( + requestPermissionActivityContract + ) { granted -> onHealthConnectPermissionCallback(granted) } } - + override fun onDetachedFromActivityForConfigChanges() { onDetachedFromActivity() } @@ -192,8 +203,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } /** - * Called when activity is detached from plugin. - * Cleans up activity-specific resources and permission launchers. + * Called when activity is detached from plugin. Cleans up activity-specific resources and + * permission launchers. */ override fun onDetachedFromActivity() { if (channel == null) { @@ -204,8 +215,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } /** - * Checks Health Connect availability and SDK status on the current device. - * Determines if Health Connect is installed and accessible. + * Checks Health Connect availability and SDK status on the current device. Determines if Health + * Connect is installed and accessible. */ private fun checkAvailability() { healthConnectStatus = HealthConnectClient.getSdkStatus(context!!) @@ -213,62 +224,74 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } /** - * Initializes helper classes for data operations after Health Connect client is ready. - * Creates instances of reader, writer, operations, and converter classes. + * Initializes helper classes for data operations after Health Connect client is ready. Creates + * instances of reader, writer, operations, and converter classes. */ private fun initializeHelpers() { dataConverter = HealthDataConverter() dataReader = HealthDataReader(healthConnectClient, scope, context!!, dataConverter) dataWriter = HealthDataWriter(healthConnectClient, scope) - dataOperations = HealthDataOperations(healthConnectClient, scope, healthConnectStatus, healthConnectAvailable) + dataOperations = + HealthDataOperations( + healthConnectClient, + scope, + healthConnectStatus, + healthConnectAvailable + ) } /** - * Launches Health Connect installation flow via Google Play Store. - * Directs users to install Health Connect when it's not available. - * + * Launches Health Connect installation flow via Google Play Store. Directs users to install + * Health Connect when it's not available. + * * @param call Method call from Flutter (unused) * @param result Flutter result callback */ private fun installHealthConnect(call: MethodCall, result: Result) { val uriString = - "market://details?id=com.google.android.apps.healthdata&url=healthconnect%3A%2F%2Fonboarding" + "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 = android.net.Uri.parse(uriString) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - putExtra("overlay", true) - putExtra("callerId", context!!.packageName) - } + Intent(Intent.ACTION_VIEW).apply { + setPackage("com.android.vending") + data = android.net.Uri.parse(uriString) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("overlay", true) + putExtra("callerId", context!!.packageName) + } ) result.success(null) } /** - * Handles permission request results from Health Connect permission dialog. - * Called when user responds to permission request, updates Flutter with result. - * + * Handles permission request results from Health Connect permission dialog. Called when user + * responds to permission request, updates Flutter with result. + * * @param permissionGranted Set of permission strings that were granted */ private fun onHealthConnectPermissionCallback(permissionGranted: Set) { if (!isReplySubmitted) { if (permissionGranted.isEmpty()) { mResult?.success(false) - Log.i("FLUTTER_HEALTH", "Health Connect permissions were not granted! Make sure to declare the required permissions in the AndroidManifest.xml file.") + Log.i( + "FLUTTER_HEALTH", + "Health Connect permissions were not granted! Make sure to declare the required permissions in the AndroidManifest.xml file." + ) } else { mResult?.success(true) - Log.i("FLUTTER_HEALTH", "${permissionGranted.size} Health Connect permissions were granted!") - Log.i("FLUTTER_HEALTH", "Permissions granted: $permissionGranted") + Log.i( + "FLUTTER_HEALTH", + "${permissionGranted.size} Health Connect permissions were granted!" + ) + Log.i("FLUTTER_HEALTH", "Permissions granted: $permissionGranted") } isReplySubmitted = true } } /** - * Initiates Health Connect permission request flow. - * Prepares permission list and launches system permission dialog. - * + * Initiates Health Connect permission request flow. Prepares permission list and launches + * system permission dialog. + * * @param call Method call containing permission types and access levels * @param result Flutter result callback for permission request outcome */ @@ -287,20 +310,20 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // Store the result to be called in onHealthConnectPermissionCallback mResult = result isReplySubmitted = false - + val permList = dataOperations.preparePermissionsList(call) if (permList == null) { result.success(false) return } - + healthConnectRequestPermissionsLauncher!!.launch(permList.toSet()) } /** - * Requests specific permission for accessing health data history. - * Launches permission dialog for historical data access capability. - * + * Requests specific permission for accessing health data history. Launches permission dialog + * for historical data access capability. + * * @param call Method call from Flutter (unused) * @param result Flutter result callback for permission request outcome */ @@ -314,14 +337,14 @@ class HealthPlugin(private var channel: MethodChannel? = null) : mResult = result isReplySubmitted = false healthConnectRequestPermissionsLauncher!!.launch( - setOf(HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY) + setOf(HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY) ) } /** - * Requests specific permission for background health data access. - * Launches permission dialog for background data reading capability. - * + * Requests specific permission for background health data access. Launches permission dialog + * for background data reading capability. + * * @param call Method call from Flutter (unused) * @param result Flutter result callback for permission request outcome */ @@ -335,7 +358,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : mResult = result isReplySubmitted = false healthConnectRequestPermissionsLauncher!!.launch( - setOf(HealthPermission.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND) + setOf(HealthPermission.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND) ) } -} \ No newline at end of file +} From 66d6893910b55167caf9ba04573d93eeafaabde5 Mon Sep 17 00:00:00 2001 From: Alireza Hajebrahimi <6937697+iarata@users.noreply.github.com> Date: Mon, 25 Aug 2025 13:32:00 +0200 Subject: [PATCH 2/4] Bump to 13.1.2: Update changelog and versions --- packages/health/CHANGELOG.md | 4 ++++ packages/health/ios/health.podspec | 2 +- packages/health/pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 986e322fd..39a337712 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,3 +1,7 @@ +## 13.1.2 + +* Fix [#1250](https://github.com/cph-cachet/flutter-plugins/issues/1250) + ## 13.1.1 * Fix [#1207](https://github.com/cph-cachet/flutter-plugins/issues/1207) - (**Important**: Some property names might have changed compared to before for `Nutrition`) diff --git a/packages/health/ios/health.podspec b/packages/health/ios/health.podspec index e3cb939ad..f291c170a 100644 --- a/packages/health/ios/health.podspec +++ b/packages/health/ios/health.podspec @@ -3,7 +3,7 @@ # Pod::Spec.new do |s| s.name = 'health' - s.version = '13.1.1' + s.version = '13.1.2' s.summary = 'Wrapper for Apple\'s HealthKit on iOS and Google\'s Health Connect on Android.' s.description = <<-DESC Wrapper for Apple's HealthKit on iOS and Google's Health Connect on Android. diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index c86b2a5e8..2c146a3aa 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -1,6 +1,6 @@ name: health description: Wrapper for Apple's HealthKit on iOS and Google's Health Connect on Android. -version: 13.1.1 +version: 13.1.2 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: From 25efa5152df548bf3b04619068bd6d0772c8a6f6 Mon Sep 17 00:00:00 2001 From: Alireza Hajebrahimi <6937697+iarata@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:55:04 +0200 Subject: [PATCH 3/4] Fix [Health 13+] Unable to fetch ECG records #1233 --- packages/health/example/lib/main.dart | 258 +++++------------- packages/health/example/lib/util.dart | 2 +- .../health/ios/Classes/HealthDataReader.swift | 90 +++--- 3 files changed, 121 insertions(+), 229 deletions(-) diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 6df1a70e3..98b2aee13 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -98,8 +98,7 @@ class HealthAppState extends State { } /// Install Google Health Connect on this phone. - Future installHealthConnect() async => - await health.installHealthConnect(); + Future installHealthConnect() async => await health.installHealthConnect(); /// Authorize, i.e. get permissions to access relevant health data. Future authorize() async { @@ -112,8 +111,7 @@ class HealthAppState extends State { await Permission.location.request(); // Check if we have health permissions - bool? hasPermissions = - await health.hasPermissions(types, permissions: permissions); + bool? hasPermissions = await health.hasPermissions(types, permissions: permissions); // hasPermissions = false because the hasPermission cannot disclose if WRITE access exists. // Hence, we have to request with WRITE as well. @@ -123,22 +121,19 @@ class HealthAppState extends State { if (!hasPermissions) { // requesting access to the data types before reading them try { - authorized = - await health.requestAuthorization(types, permissions: permissions); - + authorized = await health.requestAuthorization(types, permissions: permissions); + // request access to read historic data await health.requestHealthDataHistoryAuthorization(); // request access in background await health.requestHealthDataInBackgroundAuthorization(); - } catch (error) { debugPrint("Exception in authorize: $error"); } } - setState(() => _state = - (authorized) ? AppState.AUTHORIZED : AppState.AUTH_NOT_GRANTED); + setState(() => _state = (authorized) ? AppState.AUTHORIZED : AppState.AUTH_NOT_GRANTED); } /// Gets the Health Connect status on Android. @@ -148,8 +143,7 @@ class HealthAppState extends State { final status = await health.getHealthConnectSdkStatus(); setState(() { - _contentHealthConnectStatus = - Text('Health Connect Status: ${status?.name.toUpperCase()}'); + _contentHealthConnectStatus = Text('Health Connect Status: ${status?.name.toUpperCase()}'); _state = AppState.HEALTH_CONNECT_STATUS; }); } @@ -181,8 +175,7 @@ class HealthAppState extends State { healthData.sort((a, b) => b.dateTo.compareTo(a.dateTo)); // save all the new data points (only the first 100) - _healthDataList.addAll( - (healthData.length < 100) ? healthData : healthData.sublist(0, 100)); + _healthDataList.addAll((healthData.length < 100) ? healthData : healthData.sublist(0, 100)); } catch (error) { debugPrint("Exception in getHealthDataFromTypes: $error"); } @@ -191,7 +184,7 @@ class HealthAppState extends State { _healthDataList = health.removeDuplicates(_healthDataList); for (var data in _healthDataList) { - debugPrint(toJsonString(data)); + debugPrint(data.toJson().toString()); } // update the UI to display the results @@ -215,82 +208,30 @@ 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); - success &= await health.writeHealthData( - value: 90, - type: HealthDataType.WEIGHT, - startTime: now, - recordingMethod: RecordingMethod.manual); + value: 1.925, type: HealthDataType.HEIGHT, startTime: earlier, endTime: now, recordingMethod: RecordingMethod.manual); + success &= await health.writeHealthData(value: 90, type: HealthDataType.WEIGHT, startTime: now, recordingMethod: RecordingMethod.manual); 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); 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); 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: 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.writeInsulinDelivery(5, InsulinDeliveryReason.BOLUS, earlier, now); - success &= await health.writeHealthData( - value: 1.8, - type: HealthDataType.WATER, - 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( - 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); - success &= await health.writeHealthData( - value: 22, - type: HealthDataType.LEAN_BODY_MASS, - startTime: earlier, - endTime: 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); + success &= await health.writeHealthData(value: 22, type: HealthDataType.LEAN_BODY_MASS, startTime: earlier, endTime: now); // specialized write methods success &= await health.writeBloodOxygen( @@ -382,13 +323,8 @@ class HealthAppState extends State { endTime: now, ); - if (Platform.isIOS) { - success &= await health.writeHealthData( - value: 30, - type: HealthDataType.HEART_RATE_VARIABILITY_SDNN, - startTime: earlier, - endTime: now); + success &= await health.writeHealthData(value: 30, type: HealthDataType.HEART_RATE_VARIABILITY_SDNN, startTime: earlier, endTime: now); success &= await health.writeHealthData( value: 1.5, // 1.5 m/s (typical walking speed) type: HealthDataType.WALKING_SPEED, @@ -402,34 +338,18 @@ class HealthAppState extends State { startTime: earlier, endTime: now, recordingMethod: RecordingMethod.manual); - success &= await health.writeHealthData( - value: 30, - type: HealthDataType.HEART_RATE_VARIABILITY_RMSSD, - startTime: earlier, - endTime: now); + success &= await health.writeHealthData(value: 30, type: HealthDataType.HEART_RATE_VARIABILITY_RMSSD, startTime: earlier, endTime: now); } // Available on iOS or 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); 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); 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); } setState(() { @@ -484,18 +404,14 @@ class HealthAppState extends State { final now = DateTime.now(); final midnight = DateTime(now.year, now.month, now.day); - bool stepsPermission = - await health.hasPermissions([HealthDataType.STEPS]) ?? false; + bool stepsPermission = await health.hasPermissions([HealthDataType.STEPS]) ?? false; if (!stepsPermission) { - stepsPermission = - await health.requestAuthorization([HealthDataType.STEPS]); + stepsPermission = await health.requestAuthorization([HealthDataType.STEPS]); } if (stepsPermission) { try { - steps = await health.getTotalStepsInInterval(midnight, now, - includeManualEntry: - !recordingMethodsToFilter.contains(RecordingMethod.manual)); + steps = await health.getTotalStepsInInterval(midnight, now, includeManualEntry: !recordingMethodsToFilter.contains(RecordingMethod.manual)); } catch (error) { debugPrint("Exception in getTotalStepsInInterval: $error"); } @@ -526,9 +442,7 @@ class HealthAppState extends State { } setState(() { - _state = success - ? AppState.PERMISSIONS_REVOKED - : AppState.PERMISSIONS_NOT_REVOKED; + _state = success ? AppState.PERMISSIONS_REVOKED : AppState.PERMISSIONS_NOT_REVOKED; }); } @@ -536,16 +450,14 @@ class HealthAppState extends State { final startDate = DateTime.now().subtract(const Duration(days: 7)); final endDate = DateTime.now(); - List healthDataResponse = - await health.getHealthIntervalDataFromTypes( + List healthDataResponse = await health.getHealthIntervalDataFromTypes( startDate: startDate, endDate: endDate, types: [HealthDataType.BLOOD_OXYGEN, HealthDataType.STEPS], interval: 86400, // 86400 seconds = 1 day // recordingMethodsToFilter: recordingMethodsToFilter, ); - debugPrint( - 'Total number of interval data points: ${healthDataResponse.length}. ' + debugPrint('Total number of interval data points: ${healthDataResponse.length}. ' '${healthDataResponse.length > 100 ? 'Only showing the first 100.' : ''}'); debugPrint("Interval data points: "); @@ -555,8 +467,7 @@ class HealthAppState extends State { healthDataResponse.sort((a, b) => b.dateTo.compareTo(a.dateTo)); _healthDataList.clear(); - _healthDataList.addAll( - (healthDataResponse.length < 100) ? healthDataResponse : healthDataResponse.sublist(0, 100)); + _healthDataList.addAll((healthDataResponse.length < 100) ? healthDataResponse : healthDataResponse.sublist(0, 100)); for (var data in _healthDataList) { debugPrint(toJsonString(data)); @@ -584,73 +495,43 @@ class HealthAppState extends State { if (Platform.isAndroid) TextButton( onPressed: getHealthConnectSdkStatus, - style: const ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.blue)), - child: const Text("Check Health Connect Status", - style: TextStyle(color: Colors.white))), - if (Platform.isAndroid && - health.healthConnectSdkStatus != - HealthConnectSdkStatus.sdkAvailable) + style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.blue)), + child: const Text("Check Health Connect Status", style: TextStyle(color: Colors.white))), + if (Platform.isAndroid && health.healthConnectSdkStatus != HealthConnectSdkStatus.sdkAvailable) TextButton( onPressed: installHealthConnect, - style: const ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.blue)), - child: const Text("Install Health Connect", - style: TextStyle(color: Colors.white))), - if (Platform.isIOS || - Platform.isAndroid && - health.healthConnectSdkStatus == - HealthConnectSdkStatus.sdkAvailable) + style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.blue)), + child: const Text("Install Health Connect", style: TextStyle(color: Colors.white))), + if (Platform.isIOS || Platform.isAndroid && health.healthConnectSdkStatus == HealthConnectSdkStatus.sdkAvailable) Wrap(spacing: 10, children: [ TextButton( onPressed: authorize, - style: const ButtonStyle( - backgroundColor: - WidgetStatePropertyAll(Colors.blue)), - child: const Text("Authenticate", - style: TextStyle(color: Colors.white))), + style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.blue)), + child: const Text("Authenticate", style: TextStyle(color: Colors.white))), TextButton( onPressed: fetchData, - style: const ButtonStyle( - backgroundColor: - WidgetStatePropertyAll(Colors.blue)), - child: const Text("Fetch Data", - style: TextStyle(color: Colors.white))), + style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.blue)), + child: const Text("Fetch Data", style: TextStyle(color: Colors.white))), TextButton( onPressed: addData, - style: const ButtonStyle( - backgroundColor: - WidgetStatePropertyAll(Colors.blue)), - child: const Text("Add Data", - style: TextStyle(color: Colors.white))), + style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.blue)), + child: const Text("Add Data", style: TextStyle(color: Colors.white))), TextButton( onPressed: deleteData, - style: const ButtonStyle( - backgroundColor: - WidgetStatePropertyAll(Colors.blue)), - child: const Text("Delete Data", - style: TextStyle(color: Colors.white))), + style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.blue)), + child: const Text("Delete Data", style: TextStyle(color: Colors.white))), TextButton( onPressed: fetchStepData, - style: const ButtonStyle( - backgroundColor: - WidgetStatePropertyAll(Colors.blue)), - child: const Text("Fetch Step Data", - style: TextStyle(color: Colors.white))), + style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.blue)), + child: const Text("Fetch Step Data", style: TextStyle(color: Colors.white))), TextButton( onPressed: revokeAccess, - style: const ButtonStyle( - backgroundColor: - WidgetStatePropertyAll(Colors.blue)), - child: const Text("Revoke Access", - style: TextStyle(color: Colors.white))), + style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.blue)), + child: const Text("Revoke Access", style: TextStyle(color: Colors.white))), TextButton( onPressed: getIntervalBasedData, - style: const ButtonStyle( - backgroundColor: - WidgetStatePropertyAll(Colors.blue)), - child: const Text('Get Interval Data (7 days)', - style: TextStyle(color: Colors.white))), + style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(Colors.blue)), + child: const Text('Get Interval Data (7 days)', style: TextStyle(color: Colors.white))), ]), ], ), @@ -682,8 +563,7 @@ class HealthAppState extends State { SizedBox( width: 150, child: CheckboxListTile( - title: Text( - '${method.name[0].toUpperCase()}${method.name.substring(1)} entries'), + title: Text('${method.name[0].toUpperCase()}${method.name.substring(1)} entries'), value: !recordingMethodsToFilter.contains(method), onChanged: (value) { setState(() { @@ -717,8 +597,7 @@ class HealthAppState extends State { SizedBox( width: 150, child: CheckboxListTile( - title: Text( - '${method.name[0].toUpperCase()}${method.name.substring(1)} entries'), + title: Text('${method.name[0].toUpperCase()}${method.name.substring(1)} entries'), value: !recordingMethodsToFilter.contains(method), onChanged: (value) { setState(() { @@ -756,8 +635,7 @@ class HealthAppState extends State { Widget get _permissionsRevoked => const Text('Permissions revoked.'); - Widget get _permissionsNotRevoked => - const Text('Failed to revoke permissions'); + Widget get _permissionsNotRevoked => const Text('Failed to revoke permissions'); Widget get _contentFetchingData => Column( mainAxisAlignment: MainAxisAlignment.center, @@ -775,8 +653,7 @@ class HealthAppState extends State { itemCount: _healthDataList.length, itemBuilder: (_, index) { // filter out manual entires if not wanted - if (recordingMethodsToFilter - .contains(_healthDataList[index].recordingMethod)) { + if (recordingMethodsToFilter.contains(_healthDataList[index].recordingMethod)) { return Container(); } @@ -792,17 +669,14 @@ class HealthAppState extends State { 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), + 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'), + 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}'), ); } @@ -815,8 +689,7 @@ class HealthAppState extends State { final Widget _contentNoData = const Text('No Data to show'); - final Widget _contentNotFetched = - const Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + final Widget _contentNotFetched = const Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Text("Press 'Auth' to get permissions to access health data."), Text("Press 'Fetch Dat' to get health data."), Text("Press 'Add Data' to add some random health data."), @@ -829,14 +702,12 @@ class HealthAppState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Authorization not given.'), - Text( - 'For Google Health Connect please check if you have added the right permissions and services to the manifest file.'), + Text('For Google Health Connect please check if you have added the right permissions and services to the manifest file.'), Text('For Apple Health check your permissions in Apple Health.'), ], ); - Widget _contentHealthConnectStatus = const Text( - 'No status, click getHealthConnectSdkStatus to get the status.'); + Widget _contentHealthConnectStatus = const Text('No status, click getHealthConnectSdkStatus to get the status.'); final Widget _dataAdded = const Text('Data points inserted successfully.'); @@ -844,8 +715,7 @@ class HealthAppState extends State { Widget get _stepsFetched => Text('Total number of steps: $_nofSteps.'); - final Widget _dataNotAdded = - const Text('Failed to add data.\nDo you have permissions to add data?'); + final Widget _dataNotAdded = const Text('Failed to add data.\nDo you have permissions to add data?'); final Widget _dataNotDeleted = const Text('Failed to delete data'); diff --git a/packages/health/example/lib/util.dart b/packages/health/example/lib/util.dart index 1b1c6cfa0..a3e9a6961 100644 --- a/packages/health/example/lib/util.dart +++ b/packages/health/example/lib/util.dart @@ -52,7 +52,7 @@ const List dataTypesIOS = [ HealthDataType.LEAN_BODY_MASS, // note that a phone cannot write these ECG-based types - only read them - // HealthDataType.ELECTROCARDIOGRAM, + HealthDataType.ELECTROCARDIOGRAM, // HealthDataType.HIGH_HEART_RATE_EVENT, // HealthDataType.IRREGULAR_HEART_RATE_EVENT, // HealthDataType.LOW_HEART_RATE_EVENT, diff --git a/packages/health/ios/Classes/HealthDataReader.swift b/packages/health/ios/Classes/HealthDataReader.swift index 49d6ef7ad..675c64460 100644 --- a/packages/health/ios/Classes/HealthDataReader.swift +++ b/packages/health/ios/Classes/HealthDataReader.swift @@ -303,16 +303,14 @@ class HealthDataReader { } } else { if #available(iOS 14.0, *), let ecgSamples = samples as? [HKElectrocardiogram] { - let dictionaries = ecgSamples.map(self.fetchEcgMeasurements) - DispatchQueue.main.async { - result(dictionaries) - } + self.fetchEcgMeasurements(ecgSamples, result) } else { DispatchQueue.main.async { print("Error getting ECG - only available on iOS 14.0 and above!") result(nil) } } + } } @@ -546,38 +544,62 @@ class HealthDataReader { /// - Parameter sample: ECG sample /// - Returns: Dictionary with ECG data @available(iOS 14.0, *) - private func fetchEcgMeasurements(_ sample: HKElectrocardiogram) -> NSDictionary { - let semaphore = DispatchSemaphore(value: 0) - var voltageValues = [NSDictionary]() - let voltageQuery = HKElectrocardiogramQuery(sample) { query, result in - switch result { - case let .measurement(measurement): - if let voltageQuantity = measurement.quantity(for: .appleWatchSimilarToLeadI) { - let voltage = voltageQuantity.doubleValue(for: HKUnit.volt()) - let timeSinceSampleStart = measurement.timeSinceSampleStart - voltageValues.append(["voltage": voltage, "timeSinceSampleStart": timeSinceSampleStart]) + private func fetchEcgMeasurements(_ ecgSample: [HKElectrocardiogram], _ result: @escaping FlutterResult) { + let group = DispatchGroup() + var dictionaries = [NSDictionary]() + let lock = NSLock() + + for ecg in ecgSample { + group.enter() + + var voltageValues = [[String: Any]]() + let expected = Int(ecg.numberOfVoltageMeasurements) + if expected > 0 { + voltageValues.reserveCapacity(expected) + } + + let q = HKElectrocardiogramQuery(ecg) { _, res in + switch res { + case .measurement(let m): + if let v = m.quantity(for: .appleWatchSimilarToLeadI)? + .doubleValue(for: HKUnit.volt()) { + voltageValues.append([ + "voltage": v, + "timeSinceSampleStart": m.timeSinceSampleStart + ]) + } + case .done: + let dict: NSDictionary = [ + "uuid": "\(ecg.uuid)", + "voltageValues": voltageValues, + "averageHeartRate": ecg.averageHeartRate? + .doubleValue(for: HKUnit.count() + .unitDivided(by: HKUnit.minute())), + "samplingFrequency": ecg.samplingFrequency? + .doubleValue(for: HKUnit.hertz()), + "classification": ecg.classification.rawValue, + "date_from": Int(ecg.startDate.timeIntervalSince1970 * 1000), + "date_to": Int(ecg.endDate.timeIntervalSince1970 * 1000), + "source_id": ecg.sourceRevision.source.bundleIdentifier, + "source_name": ecg.sourceRevision.source.name + ] + lock.lock() + dictionaries.append(dict) + lock.unlock() + group.leave() + case .error(let e): + print("ECG query error: \(e)") + group.leave() + @unknown default: + print("ECG query unknown result") + group.leave() } - case .done: - semaphore.signal() - case let .error(error): - print(error) - @unknown default: - print("Unknown error occurred") } + self.healthStore.execute(q) + } + + group.notify(queue: .main) { + result(dictionaries) } - healthStore.execute(voltageQuery) - semaphore.wait() - return [ - "uuid": "\(sample.uuid)", - "voltageValues": voltageValues, - "averageHeartRate": sample.averageHeartRate?.doubleValue( - for: HKUnit.count().unitDivided(by: HKUnit.minute())), - "samplingFrequency": sample.samplingFrequency?.doubleValue(for: HKUnit.hertz()), - "classification": sample.classification.rawValue, - "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, - ] } } From 915350f562c29272c197d7269e784e779ea25aed Mon Sep 17 00:00:00 2001 From: Alireza Hajebrahimi <6937697+iarata@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:08:58 +0200 Subject: [PATCH 4/4] Update CHANGELOG --- packages/health/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 39a337712..5ce97aa64 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,6 +1,7 @@ ## 13.1.2 * Fix [#1250](https://github.com/cph-cachet/flutter-plugins/issues/1250) +* Fix [#1233](https://github.com/cph-cachet/flutter-plugins/issues/1233) ## 13.1.1