A Flutter plugin for Apple's WorkoutKit. Use it to build custom workouts in Dart, then preview, validate, export and schedule them for the Workout app on Apple Watch.
Requirements: iOS 17.0+ and an Apple Watch paired with the iPhone to actually run workouts. Pool swim goals and step names need iOS 18.0+. Android is not supported: the widgets render nothing and the API calls throw.
1.0.0 is the first stable release; the API follows semantic versioning. Upgrading from 0.0.x? See the migration guide and the CHANGELOG. Please report issues and feature requests on GitHub.
- Four workout types: custom interval workouts, single goal, pacer and swim-bike-run.
- Goals: distance, time, energy, open, and pool swim distance-within-time.
- Alerts: heart rate, cadence, power, speed and pace.
- Preview a workout in Apple's native sheet, from which the user adds it to their Watch, either from your own Flutter UI (
WorkoutKit.presentPreview) or with a ready-made native button. - Schedule workouts directly onto the Watch with
WorkoutScheduler. - Validate a workout against WorkoutKit's own rules before showing it, with structured issues (code and path) you can localize.
- Value semantics: all models support
==,hashCodeandcopyWith. - Export and import workouts in WorkoutKit's binary format.
dependencies:
flutter_workoutkit: ^1.0.0Set your iOS deployment target to 17.0 or higher in Xcode (Runner target β Minimum Deployments). If your app still uses CocoaPods, set it in ios/Podfile too:
platform :ios, '17.0'The plugin supports Swift Package Manager, which Flutter uses by default, and CocoaPods.
You don't need a HealthKit entitlement or HealthKit usage descriptions for previewing or scheduling. You only need them if you still call the deprecated WorkoutKit.requestHealthPermissions.
import 'package:flutter_workoutkit/flutter_workoutkit.dart';
final tempoRun = CustomWorkout(
activityType: WorkoutActivityType.running,
location: WorkoutLocationType.outdoor,
displayName: 'Tempo Run',
warmup: WorkoutStep(
goal: const WorkoutGoal.time(Duration(minutes: 10)),
alert: HeartRateZoneAlert(zone: 2),
),
blocks: [
IntervalBlock(iterations: 3, steps: [
IntervalStep(
purpose: IntervalStepPurpose.work,
goal: const WorkoutGoal.distance(2, WorkoutGoalUnit.kilometers),
alert: PaceRangeAlert(
fastestPace: const Duration(minutes: 5),
slowestPace: const Duration(minutes: 5, seconds: 30),
),
),
IntervalStep(
purpose: IntervalStepPurpose.recovery,
goal: const WorkoutGoal.time(Duration(seconds: 90),
unit: WorkoutGoalUnit.seconds),
),
]),
],
cooldown: WorkoutStep(goal: const WorkoutGoal.time(Duration(minutes: 5))),
);
// 1. Let the user preview the workout and add it to their Watch from any
// Flutter button:
FilledButton(
onPressed: () => WorkoutKit.presentPreview(tempoRun),
child: const Text('Add to Apple Watch'),
);
// 2. Or use the ready-made native button:
WorkoutPreviewButton(workout: tempoRun);
// 3. Or schedule it directly:
if (await WorkoutScheduler.requestAuthorization() ==
SchedulerAuthorizationState.authorized) {
await WorkoutScheduler.schedule(tempoRun, DateTime(2026, 10, 12, 7, 0));
}There are more workouts in lib/src/samples. The example app shows every feature: the native button and presentPreview, validation with structured issues, customizing a workout with copyWith, export/import and scheduling.
| Class | Description |
|---|---|
CustomWorkout |
An optional warmup, then IntervalBlocks of work/recovery IntervalSteps repeated iterations times, then an optional cooldown. |
SingleGoalWorkout |
One goal for the whole workout. Swimming takes a swimmingLocation; every other activity takes a location. |
PacerWorkout |
Cover a distance in a target time. |
SwimBikeRunWorkout |
Ordered multisport legs (MultisportActivity). |
Not every activity supports every workout type, goal or alert; for example, a pacer workout only supports a few activities. Check up front with WorkoutKit.validate.
Pool swimming: use a CustomWorkout with activityType: swimming and location: indoor. WorkoutKit does not accept pool swim goals in a SingleGoalWorkout.
| Constructor | Available on |
|---|---|
WorkoutGoal.open() |
iOS 17 |
WorkoutGoal.distance(5, WorkoutGoalUnit.kilometers) (meters, kilometers, miles, yards) |
iOS 17 |
WorkoutGoal.time(Duration(minutes: 30), unit: WorkoutGoalUnit.minutes) |
iOS 17 |
WorkoutGoal.energy(400) (kilocalories by default) |
iOS 17 |
WorkoutGoal.poolSwimDistanceWithTime(distance: 100, distanceUnit: WorkoutGoalUnit.meters, duration: Duration(minutes: 2)) |
iOS 18 |
Durations are sent exactly; unit only controls how the Watch displays them.
WorkoutGoalUnit.calories are small calories (1000 cal = 1 kcal). Most apps mean kilocalories.
| Alert | Notes |
|---|---|
HeartRateZoneAlert(zone: 1β5) |
Uses the zones configured in the Workout app. |
HeartRateRangeAlert(lowerBound:, upperBound:) |
Beats per minute. |
CadenceRangeAlert / CadenceThresholdAlert |
Steps or revolutions per minute. |
PowerZoneAlert / PowerRangeAlert / PowerThresholdAlert |
Watts. The average metric needs iOS 17.4+. |
SpeedRangeAlert / SpeedThresholdAlert |
m/s, km/h, mph or knots, using the current or average speed. |
PaceRangeAlert(fastestPace:, slowestPace:) / PaceThresholdAlert(pace:) |
Time per km or per mile. |
WorkoutKit has no separate pace alert. Pace alerts are sent to the Watch as speed alerts, and the Watch shows them as pace. A workout read back from WorkoutKit (from the scheduler or importWorkout) therefore returns them as SpeedRangeAlert/SpeedThresholdAlert in m/s.
WorkoutStep(displayName: ...) names a step on the Watch on iOS 18+. On iOS 17 the name is ignored.
Apple's preview sheet shows the workout and lets the user add it to the Workout app on their Apple Watch. There are two ways to open it.
FilledButton(
onPressed: () async {
try {
await WorkoutKit.presentPreview(tempoRun); // completes when the sheet closes
} on WorkoutkitException catch (e) {
// UNSUPPORTED_WORKOUT, UNSUPPORTED_OS, ALREADY_PRESENTING, NO_PRESENTER,
// or UNSUPPORTED_PLATFORM when not on iOS.
debugPrint('${e.code}: ${e.message}');
}
},
child: const Text('Add to Apple Watch'),
)Use any widget, gesture or flow, for example right after the user saves a workout in your app. Only one preview can be open at a time.
WorkoutPreviewButton embeds a native button that opens the same sheet, with no extra code:
WorkoutPreviewButton(
workout: tempoRun,
buttonTitle: 'Add to Apple Watch',
sfSymbol: 'applewatch', // optional, no icon by default
backgroundColor: Colors.black,
foregroundColor: Colors.white,
cornerRadiusSize: 10,
buttonHeight: 54,
fontSize: 16,
fontWeight: FontWeight.w600,
onError: (WorkoutkitException e) => debugPrint('${e.code}: ${e.message}'),
)If the workout can't be built (for example, it's unsupported, or it uses a pool swim goal on iOS 17), the button renders empty and calls onError. Changing workout or any styling recreates the native view.
if (!await WorkoutScheduler.isSupported()) return;
final state = await WorkoutScheduler.requestAuthorization(); // asks once
if (state != SchedulerAuthorizationState.authorized) return;
final scheduled = await WorkoutScheduler.schedule(tempoRun, DateTime(2026, 10, 12, 7, 0));
final all = await WorkoutScheduler.scheduledWorkouts();
await WorkoutScheduler.markComplete(scheduled);
await WorkoutScheduler.remove(scheduled);
await WorkoutScheduler.removeAll();
final max = await WorkoutScheduler.maxAllowedScheduledWorkoutCount();Dates are local time with minute precision. Each scheduled workout has a plan id (a UUID). You can set your own id on any workout, or let WorkoutKit generate one.
WorkoutKit crashes the app when it is given an unsupported combination, such as an activity a pacer workout doesn't support or a goal that isn't valid for the activity. The plugin runs WorkoutKit's supports⦠checks before building every workout and turns failures into errors. You can also run the checks yourself:
final result = await WorkoutKit.validate(workout);
if (!result.isValid) {
for (final issue in result.details) {
// e.g. unsupportedGoal at blocks[0].steps[1]
print('${issue.code.name} at ${issue.path}: ${issue.message}');
}
}Each WorkoutValidationIssue has a code (unsupportedActivity, unsupportedGoal, unsupportedAlert, unsupportedActivityOrdering, missingBlocks, missingActivities, unsupportedOS or invalidWorkout) and a path into the workout, such as activityType, goal or blocks[0].steps[1]. Use them to show your own (localized) messages or to highlight the step that's wrong. result.issues holds the English messages.
The same issues are available on errors: WorkoutkitException.validationIssues.
All models compare by value and can be copied with changes:
final longer = tempoRun.copyWith(
displayName: 'Long Tempo Run',
blocks: [tempoRun.blocks.first.copyWith(iterations: 5)],
);
longer == tempoRun; // false
Workout.fromJson(tempoRun.toJson()) == tempoRun; // true
{HeartRateZoneAlert(zone: 2), HeartRateZoneAlert(zone: 2)}; // a set of onecopyWith can't set a nullable field back to null; create a new object for that.
final Uint8List data = await WorkoutKit.exportWorkout(workout); // WorkoutKit's dataRepresentation
final Workout copy = await WorkoutKit.importWorkout(data);Native failures are thrown as WorkoutkitException, which has a code, a message and, for some codes, details:
code |
Meaning |
|---|---|
INVALID_ARGUMENT |
The workout JSON was malformed (a bug: please report it). |
UNSUPPORTED_WORKOUT |
WorkoutKit doesn't support this activity/goal/alert combination. See validationIssues. |
UNSUPPORTED_OS |
The feature needs a newer iOS version. See validationIssues. |
NOT_FOUND |
No scheduled workout matched the given id and date. |
SERIALIZATION_ERROR |
Export or import failed. |
ALREADY_PRESENTING |
presentPreview was called while a preview is open. |
NO_PRESENTER |
presentPreview found no visible screen to present from. |
UNSUPPORTED_PLATFORM |
Called on a platform other than iOS. |
WATCH_NOT_PAIRED |
WorkoutKit reported that no Apple Watch is paired. |
WORKOUT_APP_NOT_INSTALLED |
WorkoutKit reported that the Workout app isn't installed on the Watch. |
UNKNOWN |
Any other native error; see message. |
- iOS 17.0 is now the minimum. The podspec requires it, so CocoaPods rejects apps that target an older version. Previously the button crashed on iOS 16.
- The entry point is now
WorkoutKit.Workoutkitstill works: it's an alias, not deprecated. - HealthKit is no longer required.
WorkoutKit.requestHealthPermissionsandhasHealthPermissionsare deprecated. UseWorkoutScheduler.requestAuthorizationinstead, and remove the HealthKit capability if nothing else in your app uses it. IntervalBlock.typeis deprecated and optional. WorkoutKit never used it; setIntervalStep.purposeinstead.WorkoutAlertis now abstract.WorkoutAlert.fromJsonreturns the concrete subclass.WorkoutServicewas removed. It called a native method that never existed.WorkoutActivityType.danceis deprecated. UsecardioDanceorsocialDance.toJson()omits null fields.sampleSingleGoalEnergyWorkoutnow uses 500 kilocalories instead of 1000 small calories.- The
WorkoutPreviewButtonno longer shows the Apple logo by default. PasssfSymbolto show an icon. - The
PaceRangeAlert/PaceThresholdAlertAPI changed. It was never exported before.
See CONTRIBUTING.md. Running just check runs the analyzer, the Dart tests and the Swift tests.
- β
WorkoutPreviewButtonfor previewing workouts, with customization - β
WorkoutKit.presentPreviewto open the preview from any Flutter UI - β Structured validation issues
- β
Value equality and
copyWithon all models - β
WorkoutSchedulerand scheduled workout plans - β
poolSwimDistanceWithTimegoals - β Pace alerts
- β Validation, export and import
- β Graceful errors instead of native crashes
- β Tests (Dart, Swift and integration)
- β Swift Package Manager support (CocoaPods still works)
- β macOS support (WorkoutKit's scheduler and preview exist on macOS 15)
- β (Long term) Explore options for supporting Android
This project is licensed under the Apache License 2.0. See the LICENSE file for details.
For questions or feedback, please contact me at a@adamkramer.dev
If you find this plugin useful, please consider buying me a coffee:



