Appearance
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_flutterInitialize 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-Languagerequest 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
CFBundleLocalizationstoios/Runner/Info.plist:xml<key>CFBundleLocalizations</key> <array> <string>en</string> <string>fr</string> </array>Android — add
res/xml/locales_config.xmllisting 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
AppleLanguagesinAppDelegatebefore Flutter starts. - Android — call
AppCompatDelegate.setApplicationLocales(...)(the TrainingKit activities areAppCompatActivityand 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:
- List query — returns lightweight workout previews (
id,name,format, …) to display a catalog. It does not includetrainingKitToken. - Session content query — fetches the full session payload for one workout by
id, includingtrainingKitToken. This is the object you pass tolaunchWorkout.
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'orformat == 'CLASSIC'→ classic workout__typename == 'WorkoutVideoSession'orformat == '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 type | Trigger | Payload |
|---|---|---|
TrainingKitEventType.save | The workout completed and produced session state. | event.data, mirroring native SaveWorkoutState (richer on iOS than Android). |
TrainingKitEventType.quit | The user quit without completing the workout. | No payload. |
TrainingKitEventType.event | TrainingKit 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:
- Sending
X-TrainingKit-Devicewith every request (see Authentication). - Querying the workout list and retrieving a preview item.
- Fetching the full session payload (including
trainingKitToken) before callinglaunchWorkout.
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.
