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.