diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md index 5ce97aa64..c5f6b020a 100644 --- a/packages/health/CHANGELOG.md +++ b/packages/health/CHANGELOG.md @@ -1,3 +1,8 @@ +## 13.1.3 + +* Fix permissions issues with iOS +* Fix [#1231](https://github.com/cph-cachet/flutter-plugins/issues/1231) + ## 13.1.2 * Fix [#1250](https://github.com/cph-cachet/flutter-plugins/issues/1250) diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt index b6b861908..e04b2cb8a 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt @@ -2,33 +2,33 @@ package cachet.plugins.health import android.util.Log import androidx.health.connect.client.HealthConnectClient -import androidx.health.connect.client.feature.ExperimentalFeatureAvailabilityApi import androidx.health.connect.client.HealthConnectFeatures +import androidx.health.connect.client.feature.ExperimentalFeatureAvailabilityApi import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_READ_HEALTH_DATA_HISTORY import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND import androidx.health.connect.client.time.TimeRangeFilter import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.Result +import java.time.Instant import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import java.time.Instant /** - * Handles Health Connect operational tasks including permissions, SDK status, - * and data deletion operations. Manages the administrative aspects of Health Connect integration. + * Handles Health Connect operational tasks including permissions, SDK status, and data deletion + * operations. Manages the administrative aspects of Health Connect integration. */ class HealthDataOperations( - private val healthConnectClient: HealthConnectClient, - private val scope: CoroutineScope, - private val healthConnectStatus: Int, - private val healthConnectAvailable: Boolean + private val healthConnectClient: HealthConnectClient, + private val scope: CoroutineScope, + private val healthConnectStatus: Int, + private val healthConnectAvailable: Boolean ) { - + /** - * Retrieves the current Health Connect SDK availability status. - * Returns status codes indicating whether Health Connect is available, needs installation, etc. - * + * Retrieves the current Health Connect SDK availability status. Returns status codes indicating + * whether Health Connect is available, needs installation, etc. + * * @param call Method call from Flutter (unused) * @param result Flutter result callback to return SDK status integer */ @@ -37,9 +37,9 @@ class HealthDataOperations( } /** - * Checks if the application has been granted the requested health data permissions. - * Verifies permission status without triggering permission request dialogs. - * + * Checks if the application has been granted the requested health data permissions. Verifies + * permission status without triggering permission request dialogs. + * * @param call Method call containing 'types' (data types) and 'permissions' (access levels) * @param result Flutter result callback returning boolean permission status */ @@ -53,36 +53,38 @@ class HealthDataOperations( result.success(false) return } - + scope.launch { result.success( - healthConnectClient - .permissionController - .getGrantedPermissions() - .containsAll(permList), + healthConnectClient + .permissionController + .getGrantedPermissions() + .containsAll(permList), ) } } /** - * Prepares a list of Health Connect permission strings for authorization requests. - * Converts Flutter data types and permission levels into Health Connect permission format. - * + * Prepares a list of Health Connect permission strings for authorization requests. Converts + * Flutter data types and permission levels into Health Connect permission format. + * * @param call Method call containing 'types' and 'permissions' arrays * @return List? List of permission strings, or null if invalid types provided */ fun preparePermissionsList(call: MethodCall): List? { + Log.i("FLUTTER_HEALTH", "preparePermissionsList") + Log.i("FLUTTER_HEALTH", "call: $call") val args = call.arguments as HashMap<*, *> val types = (args["types"] as? ArrayList<*>)?.filterIsInstance()!! val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance()!! - + return preparePermissionsListInternal(types, permissions) } /** - * Revokes all previously granted Health Connect permissions for this application. - * Completely removes app access to Health Connect data. - * + * Revokes all previously granted Health Connect permissions for this application. Completely + * removes app access to Health Connect data. + * * @param call Method call from Flutter (unused) * @param result Flutter result callback returning success status */ @@ -95,9 +97,9 @@ class HealthDataOperations( } /** - * Checks if the health data history feature is available on the current device. - * History feature allows access to data from before the app was installed. - * + * Checks if the health data history feature is available on the current device. History feature + * allows access to data from before the app was installed. + * * @param call Method call from Flutter (unused) * @param result Flutter result callback returning boolean availability status */ @@ -105,36 +107,35 @@ class HealthDataOperations( fun isHealthDataHistoryAvailable(call: MethodCall, result: Result) { scope.launch { result.success( - healthConnectClient - .features - .getFeatureStatus(HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_HISTORY) == - HealthConnectFeatures.FEATURE_STATUS_AVAILABLE + healthConnectClient.features.getFeatureStatus( + HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_HISTORY + ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE ) } } /** - * Checks if the health data history permission has been granted. - * Verifies if app can access historical health data. - * + * Checks if the health data history permission has been granted. Verifies if app can access + * historical health data. + * * @param call Method call from Flutter (unused) * @param result Flutter result callback returning boolean authorization status */ fun isHealthDataHistoryAuthorized(call: MethodCall, result: Result) { scope.launch { result.success( - healthConnectClient - .permissionController - .getGrantedPermissions() - .containsAll(listOf(PERMISSION_READ_HEALTH_DATA_HISTORY)), + healthConnectClient + .permissionController + .getGrantedPermissions() + .containsAll(listOf(PERMISSION_READ_HEALTH_DATA_HISTORY)), ) } } /** - * Checks if background health data reading feature is available on device. - * Background feature allows data access when app is not in foreground. - * + * Checks if background health data reading feature is available on device. Background feature + * allows data access when app is not in foreground. + * * @param call Method call from Flutter (unused) * @param result Flutter result callback returning boolean availability status */ @@ -142,36 +143,35 @@ class HealthDataOperations( fun isHealthDataInBackgroundAvailable(call: MethodCall, result: Result) { scope.launch { result.success( - healthConnectClient - .features - .getFeatureStatus(HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND) == - HealthConnectFeatures.FEATURE_STATUS_AVAILABLE + healthConnectClient.features.getFeatureStatus( + HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND + ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE ) } } /** - * Checks if background health data reading permission has been granted. - * Verifies if app can access health data in background mode. - * + * Checks if background health data reading permission has been granted. Verifies if app can + * access health data in background mode. + * * @param call Method call from Flutter (unused) * @param result Flutter result callback returning boolean authorization status */ fun isHealthDataInBackgroundAuthorized(call: MethodCall, result: Result) { scope.launch { result.success( - healthConnectClient - .permissionController - .getGrantedPermissions() - .containsAll(listOf(PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND)), + healthConnectClient + .permissionController + .getGrantedPermissions() + .containsAll(listOf(PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND)), ) } } /** - * Deletes all health records of a specified type within a given time range. - * Performs bulk deletion based on data type and time window. - * + * Deletes all health records of a specified type within a given time range. Performs bulk + * deletion based on data type and time window. + * * @param call Method call containing 'dataTypeKey', 'startTime', and 'endTime' * @param result Flutter result callback returning boolean success status */ @@ -179,40 +179,37 @@ class HealthDataOperations( val type = call.argument("dataTypeKey")!! val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) - + if (!HealthConstants.mapToType.containsKey(type)) { Log.w("FLUTTER_HEALTH::ERROR", "Datatype $type not found in HC") result.success(false) return } - + val classType = HealthConstants.mapToType[type]!! scope.launch { try { healthConnectClient.deleteRecords( - recordType = classType, - timeRangeFilter = TimeRangeFilter.between(startTime, endTime), + recordType = classType, + timeRangeFilter = TimeRangeFilter.between(startTime, endTime), ) result.success(true) Log.i( - "FLUTTER_HEALTH::SUCCESS", - "Successfully deleted $type records between $startTime and $endTime" + "FLUTTER_HEALTH::SUCCESS", + "Successfully deleted $type records between $startTime and $endTime" ) } catch (e: Exception) { - Log.e( - "FLUTTER_HEALTH::ERROR", - "Error deleting $type records: ${e.message}" - ) + Log.e("FLUTTER_HEALTH::ERROR", "Error deleting $type records: ${e.message}") result.success(false) } } } /** - * Deletes a specific health record by its unique identifier and data type. - * Allows precise deletion of individual health records. - * + * Deletes a specific health record by its unique identifier and data type. Allows precise + * deletion of individual health records. + * * @param call Method call containing 'dataTypeKey' and 'uuid' * @param result Flutter result callback returning boolean success status */ @@ -220,26 +217,26 @@ class HealthDataOperations( val arguments = call.arguments as? HashMap<*, *> val dataTypeKey = (arguments?.get("dataTypeKey") as? String)!! val uuid = (arguments?.get("uuid") as? String)!! - + if (!HealthConstants.mapToType.containsKey(dataTypeKey)) { Log.w("FLUTTER_HEALTH::ERROR", "Datatype $dataTypeKey not found in HC") result.success(false) return } - + val classType = HealthConstants.mapToType[dataTypeKey]!! - + scope.launch { try { healthConnectClient.deleteRecords( - recordType = classType, - recordIdsList = listOf(uuid), - clientRecordIdsList = emptyList() + recordType = classType, + recordIdsList = listOf(uuid), + clientRecordIdsList = emptyList() ) result.success(true) Log.i( - "FLUTTER_HEALTH::SUCCESS", - "[Health Connect] Record with UUID $uuid was successfully deleted!" + "FLUTTER_HEALTH::SUCCESS", + "[Health Connect] Record with UUID $uuid was successfully deleted!" ) } catch (e: Exception) { Log.e("FLUTTER_HEALTH::ERROR", "Error deleting record with UUID: $uuid") @@ -251,47 +248,49 @@ class HealthDataOperations( } /** - * Internal helper method to prepare Health Connect permission strings. - * Converts data type names and access levels into proper permission format. - * + * Internal helper method to prepare Health Connect permission strings. Converts data type names + * and access levels into proper permission format. + * * @param types List of health data type strings * @param permissions List of permission level integers (0=read, 1=read+write) * @return List? Formatted permission strings, or null if invalid input */ private fun preparePermissionsListInternal( - types: List, - permissions: List + types: List, + permissions: List ): List? { val permList = mutableListOf() - + for ((i, typeKey) in types.withIndex()) { if (!HealthConstants.mapToType.containsKey(typeKey)) { - Log.w( - "FLUTTER_HEALTH::ERROR", - "Datatype $typeKey not found in HC" - ) + Log.w("FLUTTER_HEALTH::ERROR", "Datatype $typeKey not found in HC") return null } - + val access = permissions[i] val dataType = HealthConstants.mapToType[typeKey]!! - + if (access == 0) { // Read permission only permList.add( - HealthPermission.getReadPermission(dataType), + HealthPermission.getReadPermission(dataType), + ) + } else if (access == 1) { + // Write permission only + permList.add( + HealthPermission.getWritePermission(dataType), ) } else { // Read and write permissions permList.addAll( - listOf( - HealthPermission.getReadPermission(dataType), - HealthPermission.getWritePermission(dataType), - ), + listOf( + HealthPermission.getReadPermission(dataType), + HealthPermission.getWritePermission(dataType), + ), ) } } - + return permList } -} \ No newline at end of file +} diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 98b2aee13..e94fa44ce 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -74,6 +74,9 @@ class HealthAppState extends State { .map((type) => // can only request READ permissions to the following list of types on iOS [ + HealthDataType.GENDER, + HealthDataType.BLOOD_TYPE, + HealthDataType.BIRTH_DATE, HealthDataType.APPLE_MOVE_TIME, HealthDataType.APPLE_STAND_HOUR, HealthDataType.APPLE_STAND_TIME, @@ -98,7 +101,8 @@ 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 { @@ -111,7 +115,8 @@ 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. @@ -121,7 +126,8 @@ 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(); @@ -133,7 +139,8 @@ class HealthAppState extends State { } } - 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. @@ -143,7 +150,8 @@ 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; }); } @@ -175,7 +183,8 @@ 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"); } @@ -208,30 +217,81 @@ 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.HEART_RATE, startTime: earlier, endTime: now, recordingMethod: RecordingMethod.manual); + value: 90, + type: HealthDataType.WEIGHT, + startTime: now, + recordingMethod: RecordingMethod.manual); success &= await health.writeHealthData( - value: 90, type: HealthDataType.STEPS, 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); 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.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: 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(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( @@ -324,7 +384,13 @@ class HealthAppState extends State { ); if (Platform.isIOS) { - success &= await health.writeHealthData(value: 30, type: HealthDataType.HEART_RATE_VARIABILITY_SDNN, startTime: earlier, endTime: now); + success &= await health.writeInsulinDelivery( + 5, InsulinDeliveryReason.BOLUS, earlier, 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, @@ -338,18 +404,34 @@ 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(() { @@ -404,14 +486,18 @@ 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"); } @@ -442,7 +528,9 @@ class HealthAppState extends State { } setState(() { - _state = success ? AppState.PERMISSIONS_REVOKED : AppState.PERMISSIONS_NOT_REVOKED; + _state = success + ? AppState.PERMISSIONS_REVOKED + : AppState.PERMISSIONS_NOT_REVOKED; }); } @@ -450,14 +538,16 @@ 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: "); @@ -467,7 +557,9 @@ 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)); @@ -495,43 +587,73 @@ 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))), ]), ], ), @@ -563,7 +685,8 @@ 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(() { @@ -597,7 +720,8 @@ 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(() { @@ -635,7 +759,8 @@ 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, @@ -653,7 +778,8 @@ 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(); } @@ -669,14 +795,17 @@ 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}'), ); } @@ -689,7 +818,8 @@ 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."), @@ -702,12 +832,14 @@ 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.'); @@ -715,7 +847,8 @@ 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/ios/Classes/HealthDataOperations.swift b/packages/health/ios/Classes/HealthDataOperations.swift index 664e82afe..b4e111da5 100644 --- a/packages/health/ios/Classes/HealthDataOperations.swift +++ b/packages/health/ios/Classes/HealthDataOperations.swift @@ -1,5 +1,5 @@ -import HealthKit import Flutter +import HealthKit /// Class for managing health data permissions and deletion operations class HealthDataOperations { @@ -7,22 +7,24 @@ class HealthDataOperations { let dataTypesDict: [String: HKSampleType] let characteristicsTypesDict: [String: HKCharacteristicType] let nutritionList: [String] - + /// - Parameters: /// - healthStore: The HealthKit store /// - dataTypesDict: Dictionary of data types /// - characteristicsTypesDict: Dictionary of characteristic types /// - nutritionList: List of nutrition data types - init(healthStore: HKHealthStore, - dataTypesDict: [String: HKSampleType], - characteristicsTypesDict: [String: HKCharacteristicType], - nutritionList: [String]) { + init( + healthStore: HKHealthStore, + dataTypesDict: [String: HKSampleType], + characteristicsTypesDict: [String: HKCharacteristicType], + nutritionList: [String] + ) { self.healthStore = healthStore self.dataTypesDict = dataTypesDict self.characteristicsTypesDict = characteristicsTypesDict self.nutritionList = nutritionList } - + /// Check if HealthKit is available on the device /// - Parameters: /// - call: Flutter method call @@ -30,7 +32,7 @@ class HealthDataOperations { func checkIfHealthDataAvailable(call: FlutterMethodCall, result: @escaping FlutterResult) { result(HKHealthStore.isHealthDataAvailable()) } - + /// Check if we have required permissions /// - Parameters: /// - call: Flutter method call @@ -38,47 +40,48 @@ class HealthDataOperations { func hasPermissions(call: FlutterMethodCall, result: @escaping FlutterResult) throws { let arguments = call.arguments as? NSDictionary guard var types = arguments?["types"] as? [String], - var permissions = arguments?["permissions"] as? [Int], - types.count == permissions.count + var permissions = arguments?["permissions"] as? [Int], + types.count == permissions.count else { throw PluginError(message: "Invalid Arguments!") } - + if let nutritionIndex = types.firstIndex(of: HealthConstants.NUTRITION) { types.remove(at: nutritionIndex) let nutritionPermission = permissions[nutritionIndex] permissions.remove(at: nutritionIndex) - + for nutritionType in nutritionList { types.append(nutritionType) permissions.append(nutritionPermission) } } - + for (index, type) in types.enumerated() { guard let sampleType = dataTypesDict[type] else { print("Warning: Health data type '\(type)' not found in dataTypesDict") result(false) return } - + let success = hasPermission(type: sampleType, access: permissions[index]) if success == nil || success == false { result(success) return } if let characteristicType = characteristicsTypesDict[type] { - let characteristicSuccess = hasPermission(type: characteristicType, access: permissions[index]) - if (characteristicSuccess == nil || characteristicSuccess == false) { + let characteristicSuccess = hasPermission( + type: characteristicType, access: permissions[index]) + if characteristicSuccess == nil || characteristicSuccess == false { result(characteristicSuccess) return } } } - + result(true) } - + /// Check if we have permission for a specific type /// - Parameters: /// - type: The object type to check @@ -99,35 +102,47 @@ class HealthDataOperations { return nil } } - + /// Request authorization for health data /// - Parameters: /// - call: Flutter method call /// - result: Flutter result callback func requestAuthorization(call: FlutterMethodCall, result: @escaping FlutterResult) throws { guard let arguments = call.arguments as? NSDictionary, - let types = arguments["types"] as? [String], - let permissions = arguments["permissions"] as? [Int], - permissions.count == types.count + let types = arguments["types"] as? [String], + let permissions = arguments["permissions"] as? [Int], + permissions.count == types.count else { throw PluginError(message: "Invalid Arguments!") } - + var typesToRead = Set() var typesToWrite = Set() - + for (index, key) in types.enumerated() { - if (key == HealthConstants.NUTRITION) { + if key == HealthConstants.NUTRITION { for nutritionType in nutritionList { guard let nutritionData = dataTypesDict[nutritionType] else { - print("Warning: Nutrition data type '\(nutritionType)' not found in dataTypesDict") + print( + "Warning: Nutrition data type '\(nutritionType)' not found in dataTypesDict" + ) continue } - typesToWrite.insert(nutritionData) + let access = permissions[index] + switch access { + case 0: + typesToRead.insert(nutritionData) + case 1: + typesToWrite.insert(nutritionData) + default: + typesToRead.insert(nutritionData) + typesToWrite.insert(nutritionData) + } + } } else { let access = permissions[index] - + if let dataType = dataTypesDict[key] { switch access { case 0: @@ -139,90 +154,98 @@ class HealthDataOperations { typesToWrite.insert(dataType) } } - + if let characteristicsType = characteristicsTypesDict[key] { switch access { case 0: typesToRead.insert(characteristicsType) case 1: - throw PluginError(message: "Cannot request write permission for characteristic type \(characteristicsType)") + throw PluginError( + message: + "Cannot request write permission for characteristic type \(characteristicsType)" + ) default: typesToRead.insert(characteristicsType) } } - + if dataTypesDict[key] == nil && characteristicsTypesDict[key] == nil { - print("Warning: Health data type '\(key)' not found in dataTypesDict or characteristicsTypesDict") + print( + "Warning: Health data type '\(key)' not found in dataTypesDict or characteristicsTypesDict" + ) } - + } } - - if #available(iOS 13.0, *) { - healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { - (success, error) in - DispatchQueue.main.async { - result(success) - } + + healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { + (success, error) in + DispatchQueue.main.async { + result(success) } - } else { - // TODO: Add proper error handling - result(false) } + } - + /// Delete health data by date range /// - Parameters: /// - call: Flutter method call /// - result: Flutter result callback func delete(call: FlutterMethodCall, result: @escaping FlutterResult) { guard let arguments = call.arguments as? NSDictionary, - let dataTypeKey = arguments["dataTypeKey"] as? String else { + let dataTypeKey = arguments["dataTypeKey"] as? String + else { print("Error: Missing dataTypeKey in arguments") result(false) return } - + // Check if it's a characteristic type - these cannot be deleted if characteristicsTypesDict[dataTypeKey] != nil { - print("Info: Cannot delete characteristic type '\(dataTypeKey)' - these are read-only system values") + print( + "Info: Cannot delete characteristic type '\(dataTypeKey)' - these are read-only system values" + ) result(false) return } - + let startTime = (arguments["startTime"] as? NSNumber) ?? 0 let endTime = (arguments["endTime"] as? NSNumber) ?? 0 - + let dateFrom = HealthUtilities.dateFromMilliseconds(startTime.doubleValue) let dateTo = HealthUtilities.dateFromMilliseconds(endTime.doubleValue) - + guard let dataType = dataTypesDict[dataTypeKey] else { print("Warning: Health data type '\(dataTypeKey)' not found in dataTypesDict") result(false) return } - + let samplePredicate = HKQuery.predicateForSamples( withStart: dateFrom, end: dateTo, options: .strictStartDate) let ownerPredicate = HKQuery.predicateForObjects(from: HKSource.default()) let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) - + let deleteQuery = HKSampleQuery( sampleType: dataType, - predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [samplePredicate, ownerPredicate]), + predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [ + samplePredicate, ownerPredicate, + ]), limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor] ) { [weak self] x, samplesOrNil, error in guard let self = self else { return } - + guard let samplesOrNil = samplesOrNil, error == nil else { - print("Error querying \(dataType) samples: \(error?.localizedDescription ?? "Unknown error")") + print( + "Error querying \(dataType) samples: \(error?.localizedDescription ?? "Unknown error")" + ) DispatchQueue.main.async { result(false) } return } - + // Chcek if there are any samples to delete if samplesOrNil.isEmpty { print("Info: No \(dataType) samples found in the specified date range.") @@ -231,7 +254,7 @@ class HealthDataOperations { } return } - + // Delete the retrieved objects from the HealthKit store self.healthStore.delete(samplesOrNil) { (success, error) in if let err = error { @@ -242,33 +265,34 @@ class HealthDataOperations { } } } - + healthStore.execute(deleteQuery) } - + /// Delete health data by UUID /// - Parameters: /// - call: Flutter method call /// - result: Flutter result callback func deleteByUUID(call: FlutterMethodCall, result: @escaping FlutterResult) throws { guard let arguments = call.arguments as? NSDictionary, - let uuidarg = arguments["uuid"] as? String, - let dataTypeKey = arguments["dataTypeKey"] as? String else { + let uuidarg = arguments["uuid"] as? String, + let dataTypeKey = arguments["dataTypeKey"] as? String + else { throw PluginError(message: "Invalid Arguments - UUID or DataTypeKey invalid") } - + guard let dataTypeToRemove = dataTypesDict[dataTypeKey] else { print("Warning: Health data type '\(dataTypeKey)' not found in dataTypesDict") result(false) return } - + guard let uuid = UUID(uuidString: uuidarg) else { result(false) return } let predicate = HKQuery.predicateForObjects(with: [uuid]) - + let query = HKSampleQuery( sampleType: dataTypeToRemove, predicate: predicate, @@ -276,14 +300,14 @@ class HealthDataOperations { sortDescriptors: nil ) { [weak self] query, samplesOrNil, error in guard let self = self else { return } - + guard let samples = samplesOrNil, !samples.isEmpty else { DispatchQueue.main.async { result(false) } return } - + self.healthStore.delete(samples) { success, error in if let error = error { print("Error deleting sample with UUID \(uuid): \(error.localizedDescription)") @@ -293,7 +317,7 @@ class HealthDataOperations { } } } - + healthStore.execute(query) } } diff --git a/packages/health/ios/health.podspec b/packages/health/ios/health.podspec index f291c170a..d13878710 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.2' + s.version = '13.1.3' 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/lib/src/health_data_point.dart b/packages/health/lib/src/health_data_point.dart index 70dc1d882..374eb00a1 100644 --- a/packages/health/lib/src/health_data_point.dart +++ b/packages/health/lib/src/health_data_point.dart @@ -209,6 +209,17 @@ class HealthDataPoint { deviceModel == other.deviceModel; @override - int get hashCode => Object.hash(uuid, value, unit, dateFrom, dateTo, type, - sourcePlatform, sourceDeviceId, sourceId, sourceName, metadata, deviceModel); + int get hashCode => Object.hash( + uuid, + value, + unit, + dateFrom, + dateTo, + type, + sourcePlatform, + sourceDeviceId, + sourceId, + sourceName, + metadata, + deviceModel); } diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 536392458..90e7a0cd5 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -274,8 +274,8 @@ class Health { if (Platform.isIOS) return false; try { - final status = - await _channel.invokeMethod('isHealthDataInBackgroundAvailable'); + final status = await _channel + .invokeMethod('isHealthDataInBackgroundAvailable'); return status ?? false; } catch (e) { debugPrint( @@ -295,8 +295,8 @@ class Health { if (Platform.isIOS) return true; try { - final status = - await _channel.invokeMethod('isHealthDataInBackgroundAuthorized'); + final status = await _channel + .invokeMethod('isHealthDataInBackgroundAuthorized'); return status ?? false; } catch (e) { debugPrint( @@ -318,8 +318,8 @@ class Health { await _checkIfHealthConnectAvailableOnAndroid(); try { - final bool? isAuthorized = - await _channel.invokeMethod('requestHealthDataInBackgroundAuthorization'); + final bool? isAuthorized = await _channel + .invokeMethod('requestHealthDataInBackgroundAuthorization'); return isAuthorized ?? false; } catch (e) { debugPrint( @@ -592,7 +592,8 @@ class Health { } if (Platform.isIOS && type == null) { - throw ArgumentError("On iOS, both UUID and type are required to delete a record."); + throw ArgumentError( + "On iOS, both UUID and type are required to delete a record."); } Map args = { diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 2c146a3aa..91aeade3a 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.2 +version: 13.1.3 homepage: https://github.com/cph-cachet/flutter-plugins/tree/master/packages/health environment: