6/8 - Public Beta (Discord)
|See Changelog
Skip to main content

Exercise & Muscle Group GraphQL API - Flutter Integration Guide

The Exercise and Muscle Group APIs provide comprehensive access to workout exercises with rich metadata, instructional content, and hierarchical muscle group associations. This guide helps Flutter developers integrate with both Exercise and MuscleGroup GraphQL endpoints.

⚠️ CRITICAL: THIN CLIENT IMPLEMENTATION ONLY

What Flutter SHOULD do:

  • ✅ Consume GraphQL endpoints
  • ✅ Display exercise and muscle group data from server responses
  • ✅ Handle UI state management and navigation
  • ✅ Cache server responses for offline use
  • ✅ Format exercise instructions and tips for display

What Flutter MUST NOT do:

  • ❌ Implement exercise recommendation logic
  • ❌ Calculate muscle group targeting percentages
  • ❌ Perform exercise difficulty analysis
  • ❌ Make decisions based on equipment availability
  • ❌ Duplicate server-side exercise intelligence

GraphQL Schema

Exercise Type

type Exercise {
# Core identifiers
id: ID!
name: String!
slug: String!

# Basic information
description: String
level: String

# Instructional content
instructions: JSONValue
tips: [String!]!
shortYouTubeDemo: String
inDepthYouTubeExplanation: String

# Visual assets
animationFileKey: String
animationUrl: String
imageUrl: String # Flutter-compatible alias for animationUrl
videoUrl: String # Flutter-compatible alias for shortYouTubeDemo

# Muscle targeting
targetMuscleGroup: String
primeMoverMuscle: String
secondaryMuscle: String
tertiaryMuscle: String
muscleGroups: [MuscleGroup!]! # Resolved muscle group objects
primaryMuscleGroup: MuscleGroup
mainMuscleGroups: [MuscleGroup!]! # Flutter-compatible alias
secondaryMuscleGroups: [MuscleGroup!]!

# Equipment and setup
primaryEquipment: String
equipmentType: String # Flutter-compatible alias for primaryEquipment
numPrimaryItems: Int!

# Default units
defaultWeightUnit: String
defaultRepUnit: String
}

MuscleGroup Types

# Specific muscle groups (e.g., "Gluteus Maximus", "Biceps Brachii")
type MuscleGroup {
id: ID!
name: String!
generalGroup: GeneralMuscleGroup
slug: String! # URL-friendly version of name
imageName: String # Image filename for the muscle
}

# General muscle categories (e.g., "Legs", "Back", "Arms")
type GeneralMuscleGroup {
id: ID!
name: String!
specificMuscleGroups: [MuscleGroup!]!
}

Available Queries

1. Single Exercise Query

query GetExercise($id: ID!) {
exercise(id: $id) {
id
name
slug
description
level
instructions
tips
shortYouTubeDemo
inDepthYouTubeExplanation
animationUrl
imageUrl # Flutter-compatible alias
videoUrl # Flutter-compatible alias
targetMuscleGroup
primeMoverMuscle
secondaryMuscle
tertiaryMuscle
primaryEquipment
equipmentType # Flutter-compatible alias
numPrimaryItems
defaultWeightUnit
defaultRepUnit
muscleGroups {
id
name
slug
generalGroup {
id
name
}
}
primaryMuscleGroup {
id
name
slug
}
mainMuscleGroups { # Flutter-compatible alias
id
name
slug
}
secondaryMuscleGroups {
id
name
slug
}
}
}

2. Paginated Exercises Query

query GetExercises(
$first: Int
$after: String
$search: String
$muscleGroupId: String
) {
exercises(
first: $first
after: $after
search: $search
muscleGroupId: $muscleGroupId
) {
totalCount
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
name
slug
description
level
imageUrl
videoUrl
primaryEquipment
equipmentType
defaultWeightUnit
defaultRepUnit
muscleGroups {
id
name
slug
generalGroup {
id
name
}
}
primaryMuscleGroup {
id
name
slug
}
}
}
}
}

3. MuscleGroup Queries

# Get single specific muscle group
query GetMuscleGroup($id: ID!) {
muscleGroup(id: $id) {
id
name
slug
imageName
generalGroup {
id
name
}
}
}

# Get all specific muscle groups
query GetMuscleGroups {
muscleGroups {
id
name
slug
imageName
generalGroup {
id
name
}
}
}

# Get single general muscle group
query GetGeneralMuscleGroup($id: ID!) {
generalMuscleGroup(id: $id) {
id
name
specificMuscleGroups {
id
name
slug
imageName
}
}
}

# Get all general muscle groups
query GetGeneralMuscleGroups {
generalMuscleGroups {
id
name
specificMuscleGroups {
id
name
slug
imageName
}
}
}

Flutter Implementation

1. Generate Dart Models

Use a GraphQL code generator like graphql_codegen or ferry to generate type-safe Dart classes from your queries.

2. Service Classes

import 'package:graphql_flutter/graphql_flutter.dart';

class ExerciseService {
final GraphQLClient _client;

ExerciseService(this._client);

// Fetch single exercise with complete details
Future<Exercise?> getExercise(String id) async {
const String query = '''
query GetExercise(\$id: ID!) {
exercise(id: \$id) {
id
name
slug
description
level
instructions
tips
imageUrl
videoUrl
targetMuscleGroup
primeMoverMuscle
secondaryMuscle
tertiaryMuscle
equipmentType
numPrimaryItems
defaultWeightUnit
defaultRepUnit
muscleGroups {
id
name
slug
imageName
generalGroup {
id
name
}
}
primaryMuscleGroup {
id
name
slug
imageName
}
}
}
''';

final QueryOptions options = QueryOptions(
document: gql(query),
variables: {'id': id},
cachePolicy: CachePolicy.cacheFirst, // Cache exercises for offline use
);

final QueryResult result = await _client.query(options);

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

final exerciseData = result.data?['exercise'];
return exerciseData != null ? Exercise.fromJson(exerciseData) : null;
}

// Fetch exercises with pagination and filtering
Future<ExerciseConnection> getExercises({
int? first = 20,
String? after,
String? search,
String? muscleGroupId,
}) async {
const String query = '''
query GetExercises(
\$first: Int
\$after: String
\$search: String
\$muscleGroupId: String
) {
exercises(
first: \$first
after: \$after
search: \$search
muscleGroupId: \$muscleGroupId
) {
totalCount
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
name
slug
description
level
imageUrl
videoUrl
equipmentType
defaultWeightUnit
defaultRepUnit
muscleGroups {
id
name
slug
imageName
generalGroup {
id
name
}
}
primaryMuscleGroup {
id
name
slug
}
}
}
}
}
''';

final QueryOptions options = QueryOptions(
document: gql(query),
variables: {
'first': first,
'after': after,
'search': search,
'muscleGroupId': muscleGroupId,
}..removeWhere((key, value) => value == null),
cachePolicy: CachePolicy.cacheFirst,
);

final QueryResult result = await _client.query(options);

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

return ExerciseConnection.fromJson(result.data!['exercises']);
}
}

3. Usage Examples

// Search for exercises containing "push"
final exercises = await exerciseService.getExercises(
search: "push",
first: 10,
);

print('Found ${exercises.totalCount} exercises');
for (final exercise in exercises.exercises) {
print('${exercise.name} (${exercise.level}): ${exercise.description}');
print(' Equipment: ${exercise.equipmentType}');
print(' Primary Muscle: ${exercise.primeMoverMuscle}');
print(' Tips: ${exercise.tips.join(", ")}');
}

Error Handling

Common GraphQL Errors

Future<Exercise?> getExerciseWithErrorHandling(String id) async {
try {
return await exerciseService.getExercise(id);
} on OperationException catch (e) {
if (e.graphqlErrors.isNotEmpty) {
final graphqlError = e.graphqlErrors.first;

switch (graphqlError.extensions?['code']) {
case 'NOT_FOUND':
throw ExerciseNotFoundException('Exercise not found');
case 'UNAUTHORIZED':
throw UnauthorizedException('Authentication required');
case 'INVALID_MUSCLE_GROUP':
throw ValidationException('Invalid muscle group specified');
default:
throw ExerciseException('Failed to fetch exercise: ${graphqlError.message}');
}
}

if (e.linkException != null) {
throw NetworkException('Network error occurred');
}

throw ExerciseException('Unknown error occurred');
}
}

// Handle muscle group service errors
Future<List<GeneralMuscleGroup>> getMuscleGroupsWithErrorHandling() async {
try {
return await muscleGroupService.getGeneralMuscleGroups();
} on OperationException catch (e) {
if (e.graphqlErrors.isNotEmpty) {
final graphqlError = e.graphqlErrors.first;
throw MuscleGroupException('Failed to fetch muscle groups: ${graphqlError.message}');
}

if (e.linkException != null) {
throw NetworkException('Network error occurred');
}

throw MuscleGroupException('Unknown error occurred');
}
}

Custom Exception Classes

class ExerciseException implements Exception {
final String message;
ExerciseException(this.message);

@override
String toString() => 'ExerciseException: $message';
}

class ExerciseNotFoundException extends ExerciseException {
ExerciseNotFoundException(String message) : super(message);
}

class MuscleGroupException implements Exception {
final String message;
MuscleGroupException(this.message);

@override
String toString() => 'MuscleGroupException: $message';
}

class ValidationException implements Exception {
final String message;
ValidationException(this.message);

@override
String toString() => 'ValidationException: $message';
}

class NetworkException implements Exception {
final String message;
NetworkException(this.message);

@override
String toString() => 'NetworkException: $message';
}

class UnauthorizedException implements Exception {
final String message;
UnauthorizedException(this.message);

@override
String toString() => 'UnauthorizedException: $message';
}

⚠️ IMPLEMENTATION BOUNDARY

Exercise recommendation, difficulty calculation, and muscle targeting analysis require server-side processing only. Flutter must NEVER implement exercise intelligence logic locally. All exercise-related intelligence lives server-side via GraphQL endpoints.

Best Practices

1. Caching Strategy

Use GraphQL Flutter's caching capabilities for exercises and muscle groups:

final HttpLink httpLink = HttpLink('https://your-api-endpoint/graphql');

final GraphQLClient client = GraphQLClient(
link: httpLink,
cache: GraphQLCache(store: HiveStore()), // Persistent cache
defaultPolicies: DefaultPolicies(
query: Policies(
cachePolicy: CachePolicy.cacheFirst, // Cache exercises for offline use
),
),
);

2. Query Optimization

  • Only request fields you need in your UI
  • Use fragments for reusable field sets
  • Implement proper loading states
  • Cache muscle groups for offline browsing
// Fragment for common exercise fields
const String exerciseFragment = '''
fragment ExerciseFields on Exercise {
id
name
slug
description
level
imageUrl
videoUrl
equipmentType
defaultWeightUnit
defaultRepUnit
muscleGroups {
id
name
slug
}
}
''';

// Use fragment in queries
const String query = '''
query GetExercises(\$first: Int, \$search: String) {
exercises(first: \$first, search: \$search) {
edges {
node {
...ExerciseFields
}
}
}
}
\$exerciseFragment
''';

3. State Management with Provider

class ExerciseProvider extends ChangeNotifier {
final ExerciseService _exerciseService;
final MuscleGroupService _muscleGroupService;

ExerciseProvider(this._exerciseService, this._muscleGroupService);

List<Exercise> _exercises = [];
List<GeneralMuscleGroup> _muscleGroups = [];
bool _isLoading = false;
String? _error;

List<Exercise> get exercises => _exercises;
List<GeneralMuscleGroup> get muscleGroups => _muscleGroups;
bool get isLoading => _isLoading;
String? get error => _error;

Future<void> loadExercises({String? search, String? muscleGroupId}) async {
_setLoading(true);
try {
final connection = await _exerciseService.getExercises(
search: search,
muscleGroupId: muscleGroupId,
first: 50,
);
_exercises = connection.exercises;
_error = null;
} catch (e) {
_error = e.toString();
} finally {
_setLoading(false);
}
}

Future<void> loadMuscleGroups() async {
try {
_muscleGroups = await _muscleGroupService.getGeneralMuscleGroups();
_error = null;
} catch (e) {
_error = e.toString();
}
notifyListeners();
}

void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
}

4. Testing

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

class MockExerciseService extends Mock implements ExerciseService {}
class MockMuscleGroupService extends Mock implements MuscleGroupService {}

void main() {
group('ExerciseService', () {
late MockExerciseService mockService;

setUp(() {
mockService = MockExerciseService();
});

test('should return exercise with all fields when found', () async {
final mockExercise = Exercise(
id: '123',
name: 'Push Up',
slug: 'push-up',
description: 'A bodyweight exercise',
level: 'beginner',
tips: ['Keep your core tight', 'Maintain straight line'],
imageUrl: 'https://example.com/pushup.jpg',
videoUrl: 'https://youtube.com/watch?v=123',
equipmentType: 'bodyweight',
numPrimaryItems: 0,
defaultWeightUnit: 'kg',
defaultRepUnit: 'reps',
muscleGroups: [],
);

when(() => mockService.getExercise('123'))
.thenAnswer((_) async => mockExercise);

final result = await mockService.getExercise('123');

expect(result?.name, equals('Push Up'));
expect(result?.level, equals('beginner'));
expect(result?.equipmentType, equals('bodyweight'));
expect(result?.tips.length, equals(2));
});

test('should return paginated exercises', () async {
final mockConnection = ExerciseConnection(
totalCount: 100,
pageInfo: PageInfo(
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor2',
),
edges: [],
);

when(() => mockService.getExercises(first: 20, search: 'push'))
.thenAnswer((_) async => mockConnection);

final result = await mockService.getExercises(first: 20, search: 'push');

expect(result.totalCount, equals(100));
expect(result.pageInfo.hasNextPage, equals(true));
});
});

group('MuscleGroupService', () {
late MockMuscleGroupService mockService;

setUp(() {
mockService = MockMuscleGroupService();
});

test('should return general muscle groups with specific muscles', () async {
final mockGroups = [
GeneralMuscleGroup(
id: '1',
name: 'Arms',
specificMuscleGroups: [
MuscleGroup(
id: '1a',
name: 'Biceps Brachii',
slug: 'biceps-brachii',
),
],
),
];

when(() => mockService.getGeneralMuscleGroups())
.thenAnswer((_) async => mockGroups);

final result = await mockService.getGeneralMuscleGroups();

expect(result.length, equals(1));
expect(result.first.name, equals('Arms'));
expect(result.first.specificMuscleGroups.length, equals(1));
});
});
}

5. Offline Support

class OfflineExerciseProvider {
static const String _cacheKey = 'cached_exercises';
static const String _muscleCacheKey = 'cached_muscle_groups';

// Cache exercises locally for offline use
Future<void> cacheExercises(List<Exercise> exercises) async {
final prefs = await SharedPreferences.getInstance();
final exerciseJson = exercises.map((e) => e.toJson()).toList();
await prefs.setString(_cacheKey, jsonEncode(exerciseJson));
}

// Load cached exercises when offline
Future<List<Exercise>> getCachedExercises() async {
final prefs = await SharedPreferences.getInstance();
final cachedData = prefs.getString(_cacheKey);
if (cachedData == null) return [];

final List<dynamic> exerciseJson = jsonDecode(cachedData);
return exerciseJson.map((json) => Exercise.fromJson(json)).toList();
}

// Cache muscle groups for offline browsing
Future<void> cacheMuscleGroups(List<GeneralMuscleGroup> groups) async {
final prefs = await SharedPreferences.getInstance();
final groupJson = groups.map((g) => g.toJson()).toList();
await prefs.setString(_muscleCacheKey, jsonEncode(groupJson));
}
}

Architecture Guidelines

Exercise vs MuscleGroup Separation

┌─────────────────────┐    ┌─────────────────────┐
│ Flutter App │ │ GraphQL Backend │
│ │ │ │
│ • Display exercises │◄───┤ • Exercise metadata │
│ • Show muscle info │ │ • Muscle hierarchies│
│ • Handle UI state │ │ • Search/filtering │
│ • Cache responses │ │ • Business logic │
│ • Format for UI │ │ • Data relationships│
└─────────────────────┘ └─────────────────────┘

Data Flow Patterns

// ✅ CORRECT: Consume server-provided data
Widget buildExerciseCard(Exercise exercise) {
return Card(
child: Column(children: [
Text(exercise.name), // Server-provided
Text('Level: ${exercise.level}'), // Server-provided
Text('Equipment: ${exercise.equipmentType}'), // Server-provided
if (exercise.imageUrl != null)
Image.network(exercise.imageUrl!), // Server-provided URL
]),
);
}

// ❌ NEVER DO THIS: Calculate exercise difficulty locally
String calculateDifficulty(Exercise exercise) {
// This logic belongs on the server!
if (exercise.equipmentType == 'bodyweight' &&
exercise.muscleGroups.length > 3) {
return 'Advanced';
}
return 'Beginner';
}

Migration Notes

  • This API uses cursor-based pagination (Relay specification)
  • All IDs are strings, not integers
  • Exercise type includes rich metadata (instructions, tips, videos, equipment)
  • Muscle group relationships use two-tier hierarchy (General → Specific)
  • Flutter-compatible aliases provided (imageUrl, videoUrl, equipmentType)
  • Search is case-insensitive and matches exercise names
  • All business logic and recommendations come from server

Feature Scope & Prerequisites

Who: All users (no authentication required for browsing exercises) When: Available at all times for exercise browsing Where: Exercise library, workout creation, program templates Prerequisites:

  • GraphQL client configured
  • Image loading capability for exercise demonstrations
  • Video player for exercise demos (optional)

Exclusions:

  • Exercise creation/editing (admin-only functionality)
  • Custom exercise logic (server-side only)
  • Muscle group analysis (server-side only)
  • Exercise recommendations (handled by other services)

Support

For questions about this API:

  1. Check the GraphQL schema documentation via GraphiQL
  2. Review the Exercise and MuscleGroup GraphQL types
  3. Test queries using the GraphQL Playground
  4. Contact the backend team for new exercise metadata requirements
  • Workout History API: For tracking completed exercises
  • Program Plans API: For exercise templates and progressions
  • Progression Playbook API: For exercise progression recommendations
  • Workout Analytics API: For exercise performance analysis