Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -158,6 +161,7 @@ class HealthDataWriter(
val totalDistance = call.argument<Int>("totalDistance")
val recordingMethod = call.argument<Int>("recordingMethod")!!
val deviceType: Int? = call.argument<Int>("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)) {
Expand Down
36 changes: 35 additions & 1 deletion ios/Classes/HealthDataWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,14 +416,48 @@ 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,
end: dateTo,
duration: dateTo.timeIntervalSince(dateFrom),
totalEnergyBurned: totalEnergyBurned ?? nil,
totalDistance: totalDistance ?? nil,
metadata: nil
metadata: metadata
)

healthStore.save(
Expand Down
1 change: 1 addition & 0 deletions lib/health.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
8 changes: 8 additions & 0 deletions lib/src/health_plugin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> writeWorkoutData({
required HealthWorkoutActivityType activityType,
Expand All @@ -1507,6 +1509,7 @@ class Health {
int? totalDistance,
HealthDataUnit totalDistanceUnit = HealthDataUnit.METER,
String? title,
WorkoutMetadata? metadata,
RecordingMethod recordingMethod = RecordingMethod.automatic,
}) async {
await _checkIfHealthConnectAvailableOnAndroid();
Expand Down Expand Up @@ -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;
}

Expand Down
93 changes: 93 additions & 0 deletions lib/src/workout_metadata.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> toJson() {
final map = <String, dynamic>{};
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;
}