From 4e3ab4053b3c65fa585787fcd59362f1c090ba6d Mon Sep 17 00:00:00 2001 From: Kyle Venn Date: Sun, 2 Nov 2025 19:25:26 -0500 Subject: [PATCH 1/3] Adding brand name --- .../main/kotlin/cachet/plugins/health/HealthDataWriter.kt | 4 +++- ios/Classes/HealthDataWriter.swift | 8 +++++++- lib/src/health_plugin.dart | 4 ++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt b/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt index 976a9bf1..e75b7170 100644 --- a/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt +++ b/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt @@ -145,7 +145,8 @@ class HealthDataWriter( * * @param call Method call containing workout details: 'activityType', 'startTime', 'endTime', * ``` - * 'totalEnergyBurned', 'totalDistance', 'recordingMethod', 'title' + * 'totalEnergyBurned', 'totalDistance', 'recordingMethod', 'title', 'brandName' + * Note: 'brandName' is iOS-only and ignored on Android * @param result * ``` * Flutter result callback returning boolean success status @@ -158,6 +159,7 @@ class HealthDataWriter( val totalDistance = call.argument("totalDistance") val recordingMethod = call.argument("recordingMethod")!! val deviceType: Int? = call.argument("deviceType") + // Note: brandName parameter is ignored on Android as Health Connect does not support it 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..f004ed10 100644 --- a/ios/Classes/HealthDataWriter.swift +++ b/ios/Classes/HealthDataWriter.swift @@ -416,6 +416,12 @@ class HealthDataWriter { let dateFrom = HealthUtilities.dateFromMilliseconds(startTime.doubleValue) let dateTo = HealthUtilities.dateFromMilliseconds(endTime.doubleValue) + // Build metadata with optional brand name + var metadata: [String: Any]? = nil + if let brandName = arguments["brandName"] as? String { + metadata = [HKMetadataKeyWorkoutBrandName: brandName] + } + let workout = HKWorkout( activityType: activityTypeValue, start: dateFrom, @@ -423,7 +429,7 @@ class HealthDataWriter { duration: dateTo.timeIntervalSince(dateFrom), totalEnergyBurned: totalEnergyBurned ?? nil, totalDistance: totalDistance ?? nil, - metadata: nil + metadata: metadata ) healthStore.save( diff --git a/lib/src/health_plugin.dart b/lib/src/health_plugin.dart index 62fac251..e3bdd7d2 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". + /// - [brandName] The brand name of the workout (e.g., "Peloton", "Nike Run Club"). + /// *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, + String? brandName, RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { await _checkIfHealthConnectAvailableOnAndroid(); @@ -1539,6 +1542,7 @@ class Health { 'totalDistance': totalDistance, 'totalDistanceUnit': totalDistanceUnit.name, 'title': title, + 'brandName': brandName, 'recordingMethod': recordingMethod.toInt(), }; return await _channel.invokeMethod('writeWorkoutData', args) == true; From 370212d6fbf297d40a28926ebda76a75a237ee84 Mon Sep 17 00:00:00 2001 From: Kyle Venn Date: Sun, 2 Nov 2025 19:28:29 -0500 Subject: [PATCH 2/3] Add support for metadata more generally --- .../cachet/plugins/health/HealthDataWriter.kt | 8 +- ios/Classes/HealthDataWriter.swift | 29 +++++- lib/health.dart | 1 + lib/src/health_plugin.dart | 10 +- lib/src/workout_metadata.dart | 91 +++++++++++++++++++ 5 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 lib/src/workout_metadata.dart diff --git a/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt b/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt index e75b7170..838ffe16 100644 --- a/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt +++ b/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt @@ -145,8 +145,10 @@ class HealthDataWriter( * * @param call Method call containing workout details: 'activityType', 'startTime', 'endTime', * ``` - * 'totalEnergyBurned', 'totalDistance', 'recordingMethod', 'title', 'brandName' - * Note: 'brandName' is iOS-only and ignored on Android + * '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 @@ -159,7 +161,7 @@ class HealthDataWriter( val totalDistance = call.argument("totalDistance") val recordingMethod = call.argument("recordingMethod")!! val deviceType: Int? = call.argument("deviceType") - // Note: brandName parameter is ignored on Android as Health Connect does not support it + // 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 f004ed10..a88f4923 100644 --- a/ios/Classes/HealthDataWriter.swift +++ b/ios/Classes/HealthDataWriter.swift @@ -416,10 +416,33 @@ class HealthDataWriter { let dateFrom = HealthUtilities.dateFromMilliseconds(startTime.doubleValue) let dateTo = HealthUtilities.dateFromMilliseconds(endTime.doubleValue) - // Build metadata with optional brand name + // Build metadata from optional metadata dictionary var metadata: [String: Any]? = nil - if let brandName = arguments["brandName"] as? String { - metadata = [HKMetadataKeyWorkoutBrandName: brandName] + if let metadataDict = arguments["metadata"] as? [String: Any] { + var workoutMetadata = [String: Any]() + + if let activityType = metadataDict["activityType"] as? String { + workoutMetadata[HKMetadataKeyActivityType] = activityType + } + if let appleFitnessPlusSession = metadataDict["appleFitnessPlusSession"] as? Bool { + workoutMetadata[HKMetadataKeyAppleFitnessPlusSession] = appleFitnessPlusSession + } + 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( 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 e3bdd7d2..ce47f025 100644 --- a/lib/src/health_plugin.dart +++ b/lib/src/health_plugin.dart @@ -1497,7 +1497,7 @@ 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". - /// - [brandName] The brand name of the workout (e.g., "Peloton", "Nike Run Club"). + /// - [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({ @@ -1509,7 +1509,7 @@ class Health { int? totalDistance, HealthDataUnit totalDistanceUnit = HealthDataUnit.METER, String? title, - String? brandName, + WorkoutMetadata? metadata, RecordingMethod recordingMethod = RecordingMethod.automatic, }) async { await _checkIfHealthConnectAvailableOnAndroid(); @@ -1542,9 +1542,13 @@ class Health { 'totalDistance': totalDistance, 'totalDistanceUnit': totalDistanceUnit.name, 'title': title, - 'brandName': brandName, '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..0073aedb --- /dev/null +++ b/lib/src/workout_metadata.dart @@ -0,0 +1,91 @@ +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. + final String? activityType; + + /// Whether this workout is part of an Apple Fitness+ session. + /// Maps to HKMetadataKeyAppleFitnessPlusSession. + 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; +} From 54d6b057d1899e02da51edca11e41627c645598c Mon Sep 17 00:00:00 2001 From: Kyle Venn Date: Sun, 2 Nov 2025 19:35:14 -0500 Subject: [PATCH 3/3] Respect metadata only available in ios 17+ --- ios/Classes/HealthDataWriter.swift | 15 ++++++++++----- lib/src/workout_metadata.dart | 2 ++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ios/Classes/HealthDataWriter.swift b/ios/Classes/HealthDataWriter.swift index a88f4923..8d47b959 100644 --- a/ios/Classes/HealthDataWriter.swift +++ b/ios/Classes/HealthDataWriter.swift @@ -421,12 +421,17 @@ class HealthDataWriter { if let metadataDict = arguments["metadata"] as? [String: Any] { var workoutMetadata = [String: Any]() - if let activityType = metadataDict["activityType"] as? String { - workoutMetadata[HKMetadataKeyActivityType] = activityType - } - if let appleFitnessPlusSession = metadataDict["appleFitnessPlusSession"] as? Bool { - workoutMetadata[HKMetadataKeyAppleFitnessPlusSession] = appleFitnessPlusSession + // 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 } diff --git a/lib/src/workout_metadata.dart b/lib/src/workout_metadata.dart index 0073aedb..65e4290f 100644 --- a/lib/src/workout_metadata.dart +++ b/lib/src/workout_metadata.dart @@ -10,10 +10,12 @@ part of '../health.dart'; 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.