diff --git a/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt b/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt index 976a9bf1..838ffe16 100644 --- a/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt +++ b/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt @@ -145,7 +145,10 @@ class HealthDataWriter( * * @param call Method call containing workout details: 'activityType', 'startTime', 'endTime', * ``` - * 'totalEnergyBurned', 'totalDistance', 'recordingMethod', 'title' + * 'totalEnergyBurned', 'totalDistance', 'recordingMethod', 'title', 'metadata' + * Note: 'metadata' parameter (containing workout brand name, indoor/outdoor status, + * coached workout flag, etc.) is iOS-only and ignored on Android as Health Connect + * does not support workout metadata. * @param result * ``` * Flutter result callback returning boolean success status @@ -158,6 +161,7 @@ class HealthDataWriter( val totalDistance = call.argument("totalDistance") val recordingMethod = call.argument("recordingMethod")!! val deviceType: Int? = call.argument("deviceType") + // Note: metadata parameter is ignored on Android as Health Connect does not support workout metadata val workoutMetadata = buildMetadata(recordingMethod = recordingMethod, deviceType = deviceType) if (!HealthConstants.workoutTypeMap.containsKey(type)) { diff --git a/ios/Classes/HealthDataWriter.swift b/ios/Classes/HealthDataWriter.swift index df19a505..8d47b959 100644 --- a/ios/Classes/HealthDataWriter.swift +++ b/ios/Classes/HealthDataWriter.swift @@ -416,6 +416,40 @@ class HealthDataWriter { let dateFrom = HealthUtilities.dateFromMilliseconds(startTime.doubleValue) let dateTo = HealthUtilities.dateFromMilliseconds(endTime.doubleValue) + // Build metadata from optional metadata dictionary + var metadata: [String: Any]? = nil + if let metadataDict = arguments["metadata"] as? [String: Any] { + var workoutMetadata = [String: Any]() + + // iOS 17+ only metadata keys + if #available(iOS 17.0, *) { + if let activityType = metadataDict["activityType"] as? String { + workoutMetadata[HKMetadataKeyActivityType] = activityType + } + if let appleFitnessPlusSession = metadataDict["appleFitnessPlusSession"] as? Bool { + workoutMetadata[HKMetadataKeyAppleFitnessPlusSession] = appleFitnessPlusSession + } + } + + // Available on all iOS versions + if let coachedWorkout = metadataDict["coachedWorkout"] as? Bool { + workoutMetadata[HKMetadataKeyCoachedWorkout] = coachedWorkout + } + if let groupFitness = metadataDict["groupFitness"] as? Bool { + workoutMetadata[HKMetadataKeyGroupFitness] = groupFitness + } + if let indoorWorkout = metadataDict["indoorWorkout"] as? Bool { + workoutMetadata[HKMetadataKeyIndoorWorkout] = indoorWorkout + } + if let workoutBrandName = metadataDict["workoutBrandName"] as? String { + workoutMetadata[HKMetadataKeyWorkoutBrandName] = workoutBrandName + } + + if !workoutMetadata.isEmpty { + metadata = workoutMetadata + } + } + let workout = HKWorkout( activityType: activityTypeValue, start: dateFrom, @@ -423,7 +457,7 @@ class HealthDataWriter { duration: dateTo.timeIntervalSince(dateFrom), totalEnergyBurned: totalEnergyBurned ?? nil, totalDistance: totalDistance ?? nil, - metadata: nil + metadata: metadata ) healthStore.save( diff --git a/lib/health.dart b/lib/health.dart index af94d80e..62cd11f4 100644 --- a/lib/health.dart +++ b/lib/health.dart @@ -16,6 +16,7 @@ part 'src/health_data_point.dart'; part 'src/health_value_types.dart'; part 'src/health_plugin.dart'; part 'src/workout_summary.dart'; +part 'src/workout_metadata.dart'; part 'health.g.dart'; part 'health.json.dart'; diff --git a/lib/src/health_plugin.dart b/lib/src/health_plugin.dart index 62fac251..ce47f025 100644 --- a/lib/src/health_plugin.dart +++ b/lib/src/health_plugin.dart @@ -1497,6 +1497,8 @@ class Health { /// *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". + /// - [metadata] Optional workout metadata (brand name, indoor/outdoor, coached, etc.). + /// *ONLY FOR IOS* This parameter is ignored on Android. /// - [recordingMethod] The recording method of the data point, automatic by default (on iOS this can only be automatic or manual). Future writeWorkoutData({ required HealthWorkoutActivityType activityType, @@ -1507,6 +1509,7 @@ class Health { int? totalDistance, HealthDataUnit totalDistanceUnit = HealthDataUnit.METER, String? title, + WorkoutMetadata? metadata, RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { await _checkIfHealthConnectAvailableOnAndroid(); @@ -1541,6 +1544,11 @@ class Health { 'title': title, 'recordingMethod': recordingMethod.toInt(), }; + + // Add metadata if provided + if (metadata != null) { + args['metadata'] = metadata.toJson(); + } return await _channel.invokeMethod('writeWorkoutData', args) == true; } diff --git a/lib/src/workout_metadata.dart b/lib/src/workout_metadata.dart new file mode 100644 index 00000000..65e4290f --- /dev/null +++ b/lib/src/workout_metadata.dart @@ -0,0 +1,93 @@ +part of '../health.dart'; + +/// Metadata for workout data. +/// +/// All fields are optional and iOS-only. These fields are ignored on Android +/// as Health Connect does not support workout metadata. +/// +/// See Apple's HealthKit documentation for more details: +/// https://developer.apple.com/documentation/healthkit/hkmetadatakey +class WorkoutMetadata { + /// The activity type of the workout (e.g., "Running", "Cycling"). + /// Maps to HKMetadataKeyActivityType. + /// **Requires iOS 17.0 or later.** + final String? activityType; + + /// Whether this workout is part of an Apple Fitness+ session. + /// Maps to HKMetadataKeyAppleFitnessPlusSession. + /// **Requires iOS 17.0 or later.** + final bool? appleFitnessPlusSession; + + /// Whether this workout was coached. + /// Maps to HKMetadataKeyCoachedWorkout. + final bool? coachedWorkout; + + /// Whether this workout was done in a group fitness setting. + /// Maps to HKMetadataKeyGroupFitness. + final bool? groupFitness; + + /// Whether this workout was done indoors. + /// Maps to HKMetadataKeyIndoorWorkout. + final bool? indoorWorkout; + + /// The brand name associated with the workout (e.g., "Peloton", "Nike Run Club"). + /// Maps to HKMetadataKeyWorkoutBrandName. + final String? workoutBrandName; + + /// Creates a [WorkoutMetadata] instance. + /// + /// All parameters are optional and iOS-only. + const WorkoutMetadata({ + this.activityType, + this.appleFitnessPlusSession, + this.coachedWorkout, + this.groupFitness, + this.indoorWorkout, + this.workoutBrandName, + }); + + /// Converts this [WorkoutMetadata] to a JSON map. + Map toJson() { + final map = {}; + if (activityType != null) map['activityType'] = activityType; + if (appleFitnessPlusSession != null) { + map['appleFitnessPlusSession'] = appleFitnessPlusSession; + } + if (coachedWorkout != null) map['coachedWorkout'] = coachedWorkout; + if (groupFitness != null) map['groupFitness'] = groupFitness; + if (indoorWorkout != null) map['indoorWorkout'] = indoorWorkout; + if (workoutBrandName != null) map['workoutBrandName'] = workoutBrandName; + return map; + } + + @override + String toString() => + 'WorkoutMetadata(' + 'activityType: $activityType, ' + 'appleFitnessPlusSession: $appleFitnessPlusSession, ' + 'coachedWorkout: $coachedWorkout, ' + 'groupFitness: $groupFitness, ' + 'indoorWorkout: $indoorWorkout, ' + 'workoutBrandName: $workoutBrandName)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WorkoutMetadata && + runtimeType == other.runtimeType && + activityType == other.activityType && + appleFitnessPlusSession == other.appleFitnessPlusSession && + coachedWorkout == other.coachedWorkout && + groupFitness == other.groupFitness && + indoorWorkout == other.indoorWorkout && + workoutBrandName == other.workoutBrandName; + + @override + int get hashCode => + activityType.hashCode ^ + appleFitnessPlusSession.hashCode ^ + coachedWorkout.hashCode ^ + groupFitness.hashCode ^ + indoorWorkout.hashCode ^ + workoutBrandName.hashCode; +}