Skip to content

Usage

GraphQL client setup

The bridge is client-agnostic — it does not own or create a GraphQL client. The demo uses graphql_flutter; use whichever client you prefer.

sh
flutter pub add graphql_flutter

Initialize the client with the device identifier in the headers and wrap your app with GraphQLProvider:

dart
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:trainingkit_flutter/trainingkit_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final deviceId = await deviceIdentifier();
  final locale = WidgetsBinding.instance.platformDispatcher.locale;

  final link = HttpLink(
    'https://your-graphql-endpoint/graphql',
    defaultHeaders: {
      'X-TrainingKit-Device': deviceId,
      // Derived from the app locale — see "Language" below.
      'Accept-Language': '${locale.languageCode}-${locale.countryCode}',
    },
  );

  final client = ValueNotifier(GraphQLClient(link: link, cache: GraphQLCache()));

  runApp(GraphQLProvider(client: client, child: const MyApp()));
}

Language

A TrainingKit workout has two language layers, and both should resolve to the same language:

  • Content — workout names, exercise instructions, and the voice-coaching text spoken during the workout — comes from the GraphQL API and is localized from the Accept-Language request header. Without it, the API serves its default language (English), producing the common mismatch of a localized UI around English workout content.
  • Native UI — the buttons and labels of the native workout screen — is localized by the SDK from the app's locale. TrainingKit exposes no language API; it follows the device/app language like any native app.

Derive Accept-Language from the app locale instead of hard-coding it, so the content follows the same language as the native UI:

dart
final locale = WidgetsBinding.instance.platformDispatcher.locale;
final acceptLanguage = locale.countryCode == null
    ? locale.languageCode
    : '${locale.languageCode}-${locale.countryCode}';

Declare the languages your app supports

Required for the native UI

The native UI stays in English even on a localized device unless the app declares the language. iOS only serves a framework's localized resources for languages the host app lists, and Flutter apps ship with English only by default.

  • iOS — add CFBundleLocalizations to ios/Runner/Info.plist:

    xml
    <key>CFBundleLocalizations</key>
    <array>
      <string>en</string>
      <string>fr</string>
    </array>
  • Android — add res/xml/locales_config.xml listing the locales and reference it from <application android:localeConfig="@xml/locales_config">.

Info.plist / manifest changes require a full rebuild and reinstall (not a hot reload).

Pin a language regardless of the device

To force one language (as the demo does for French), set the app's locale on the native side, and the derived Accept-Language follows:

  • iOS — set AppleLanguages in AppDelegate before Flutter starts.
  • Android — call AppCompatDelegate.setApplicationLocales(...) (the TrainingKit activities are AppCompatActivity and follow the per-app locale).

The demo app does exactly this to force French; see its lib/main.dart, ios/Runner/AppDelegate.swift, and android/.../MainActivity.kt.

The two-step flow

Launching a workout always takes two GraphQL calls:

  1. List query — returns lightweight workout previews (id, name, format, …) to display a catalog. It does not include trainingKitToken.
  2. Session content query — fetches the full session payload for one workout by id, including trainingKitToken. This is the object you pass to launchWorkout.

Fetch the full session lazily, only when the user selects a workout, so you do not request a launch token for every item in the list.

Listing workouts

The GraphQL documents depend on your schema. The list query should return workout preview items that include at minimum: id, name, duration, picture, and format.

format values are CLASSIC (classic guided workout) and PLAY (video workout).

dart
Query(
  options: QueryOptions(document: gql(getSessions)),
  builder: (result, {fetchMore, refetch}) {
    if (result.isLoading) return const CircularProgressIndicator();
    if (result.hasException) return Text(result.exception.toString());

    final edges = result.data?['publicWorkouts']?['edges'] as List? ?? [];
    final nodes = edges.map((e) => e['node'] as Map<String, dynamic>).toList();

    final classic = nodes.where((n) => n['format'] == 'CLASSIC');
    final video = nodes.where((n) => n['format'] == 'PLAY');

    return ListView(
      children: [
        for (final node in [...classic, ...video])
          ListTile(
            title: Text(node['name'] as String),
            onTap: () => launchWorkoutById(context, node['id'] as String),
          ),
      ],
    );
  },
);

Launching a workout

To launch a workout, fetch the full session payload first. The full session response must include trainingKitToken.

dart
Future<void> launchWorkoutById(BuildContext context, String id) async {
  final client = GraphQLProvider.of(context).value;

  try {
    final result = await client.query(QueryOptions(
      document: gql(getSessionContent),
      variables: {'id': id},
      fetchPolicy: FetchPolicy.networkOnly,
    ));

    if (result.hasException) throw result.exception!;

    final session = result.data?['publicWorkoutSession'];
    if (session is! Map<String, dynamic>) {
      throw const TrainingKitException('Workout session not found.');
    }

    await launchWorkout(session);
  } catch (error) {
    // Surface the error in your UI (e.g. a SnackBar).
  }
}

launchWorkout determines the workout kind from the session and dispatches to the correct native SDK:

  • __typename == 'WorkoutBlockSession' or format == 'CLASSIC' → classic workout
  • __typename == 'WorkoutVideoSession' or format == 'PLAY' → video workout

If neither condition matches, launchWorkout throws a TrainingKitException with the unrecognized type.

__typename

graphql_flutter automatically adds __typename to every selection set, so the session payload it returns already carries WorkoutBlockSession / WorkoutVideoSession. You don't need to request it explicitly.

Handling workout completion

Listen to workoutEvents() before calling launchWorkout. The bridge forwards the same lifecycle for classic and video workouts on iOS and Android.

dart
final subscription = workoutEvents().listen((event) {
  switch (event.type) {
    case TrainingKitEventType.save:
      // The workout completed. Persist event.data here.
      break;
    case TrainingKitEventType.quit:
      // The user exited before completing the workout.
      break;
    case TrainingKitEventType.event:
      // Optional: forward event.name and event.properties.
      break;
  }
});

await launchWorkout(session);

// Later, for example in dispose():
await subscription.cancel();
Event typeTriggerPayload
TrainingKitEventType.saveThe workout completed and produced session state.event.data, mirroring native SaveWorkoutState (richer on iOS than Android).
TrainingKitEventType.quitThe user quit without completing the workout.No payload.
TrainingKitEventType.eventTrainingKit emitted a tracking event.event.name and event.properties.

GraphQL contract

The package does not own the GraphQL client or schema. Your application is responsible for:

  1. Sending X-TrainingKit-Device with every request (see Authentication).
  2. Querying the workout list and retrieving a preview item.
  3. Fetching the full session payload (including trainingKitToken) before calling launchWorkout.

The session object passed to launchWorkout is a Map<String, dynamic> that must contain trainingKitToken and either __typename or format. Any additional fields from the GraphQL response are preserved and forwarded to the native SDK as part of the JSON payload.