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
- ExerciseService
- MuscleGroupService
- Model 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']);
}
}
class MuscleGroupService {
final GraphQLClient _client;
MuscleGroupService(this._client);
// Fetch all general muscle groups with their specific muscles
Future<List<GeneralMuscleGroup>> getGeneralMuscleGroups() async {
const String query = '''
query GetGeneralMuscleGroups {
generalMuscleGroups {
id
name
specificMuscleGroups {
id
name
slug
imageName
}
}
}
''';
final QueryOptions options = QueryOptions(
document: gql(query),
cachePolicy: CachePolicy.cacheFirst, // Cache muscle groups
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw result.exception!;
}
return (result.data!['generalMuscleGroups'] as List)
.map((json) => GeneralMuscleGroup.fromJson(json))
.toList();
}
// Fetch all specific muscle groups
Future<List<MuscleGroup>> getMuscleGroups() async {
const String query = '''
query GetMuscleGroups {
muscleGroups {
id
name
slug
imageName
generalGroup {
id
name
}
}
}
''';
final QueryOptions options = QueryOptions(
document: gql(query),
cachePolicy: CachePolicy.cacheFirst,
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw result.exception!;
}
return (result.data!['muscleGroups'] as List)
.map((json) => MuscleGroup.fromJson(json))
.toList();
}
// Fetch single muscle group
Future<MuscleGroup?> getMuscleGroup(String id) async {
const String query = '''
query GetMuscleGroup(\$id: ID!) {
muscleGroup(id: \$id) {
id
name
slug
imageName
generalGroup {
id
name
}
}
}
''';
final QueryOptions options = QueryOptions(
document: gql(query),
variables: {'id': id},
cachePolicy: CachePolicy.cacheFirst,
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw result.exception!;
}
final muscleData = result.data?['muscleGroup'];
return muscleData != null ? MuscleGroup.fromJson(muscleData) : null;
}
}
class Exercise {
final String id;
final String name;
final String slug;
final String? description;
final String? level;
final dynamic instructions; // JSONValue from server
final List<String> tips;
final String? imageUrl;
final String? videoUrl;
final String? targetMuscleGroup;
final String? primeMoverMuscle;
final String? secondaryMuscle;
final String? tertiaryMuscle;
final String? equipmentType;
final int numPrimaryItems;
final String? defaultWeightUnit;
final String? defaultRepUnit;
final List<MuscleGroup> muscleGroups;
final MuscleGroup? primaryMuscleGroup;
Exercise({
required this.id,
required this.name,
required this.slug,
this.description,
this.level,
this.instructions,
required this.tips,
this.imageUrl,
this.videoUrl,
this.targetMuscleGroup,
this.primeMoverMuscle,
this.secondaryMuscle,
this.tertiaryMuscle,
this.equipmentType,
required this.numPrimaryItems,
this.defaultWeightUnit,
this.defaultRepUnit,
required this.muscleGroups,
this.primaryMuscleGroup,
});
factory Exercise.fromJson(Map<String, dynamic> json) {
return Exercise(
id: json['id'],
name: json['name'],
slug: json['slug'],
description: json['description'],
level: json['level'],
instructions: json['instructions'],
tips: List<String>.from(json['tips'] ?? []),
imageUrl: json['imageUrl'],
videoUrl: json['videoUrl'],
targetMuscleGroup: json['targetMuscleGroup'],
primeMoverMuscle: json['primeMoverMuscle'],
secondaryMuscle: json['secondaryMuscle'],
tertiaryMuscle: json['tertiaryMuscle'],
equipmentType: json['equipmentType'],
numPrimaryItems: json['numPrimaryItems'] ?? 0,
defaultWeightUnit: json['defaultWeightUnit'],
defaultRepUnit: json['defaultRepUnit'],
muscleGroups: (json['muscleGroups'] as List? ?? [])
.map((mg) => MuscleGroup.fromJson(mg))
.toList(),
primaryMuscleGroup: json['primaryMuscleGroup'] != null
? MuscleGroup.fromJson(json['primaryMuscleGroup'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'slug': slug,
'description': description,
'level': level,
'instructions': instructions,
'tips': tips,
'imageUrl': imageUrl,
'videoUrl': videoUrl,
'targetMuscleGroup': targetMuscleGroup,
'primeMoverMuscle': primeMoverMuscle,
'secondaryMuscle': secondaryMuscle,
'tertiaryMuscle': tertiaryMuscle,
'equipmentType': equipmentType,
'numPrimaryItems': numPrimaryItems,
'defaultWeightUnit': defaultWeightUnit,
'defaultRepUnit': defaultRepUnit,
'muscleGroups': muscleGroups.map((mg) => mg.toJson()).toList(),
'primaryMuscleGroup': primaryMuscleGroup?.toJson(),
};
}
}
class MuscleGroup {
final String id;
final String name;
final String slug;
final String? imageName;
final GeneralMuscleGroup? generalGroup;
MuscleGroup({
required this.id,
required this.name,
required this.slug,
this.imageName,
this.generalGroup,
});
factory MuscleGroup.fromJson(Map<String, dynamic> json) {
return MuscleGroup(
id: json['id'],
name: json['name'],
slug: json['slug'],
imageName: json['imageName'],
generalGroup: json['generalGroup'] != null
? GeneralMuscleGroup.fromJson(json['generalGroup'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'slug': slug,
'imageName': imageName,
'generalGroup': generalGroup?.toJson(),
};
}
}
class GeneralMuscleGroup {
final String id;
final String name;
final List<MuscleGroup> specificMuscleGroups;
GeneralMuscleGroup({
required this.id,
required this.name,
required this.specificMuscleGroups,
});
factory GeneralMuscleGroup.fromJson(Map<String, dynamic> json) {
return GeneralMuscleGroup(
id: json['id'],
name: json['name'],
specificMuscleGroups: (json['specificMuscleGroups'] as List? ?? [])
.map((mg) => MuscleGroup.fromJson(mg))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'specificMuscleGroups': specificMuscleGroups.map((mg) => mg.toJson()).toList(),
};
}
}
class ExerciseConnection {
final int totalCount;
final PageInfo pageInfo;
final List<ExerciseEdge> edges;
ExerciseConnection({
required this.totalCount,
required this.pageInfo,
required this.edges,
});
List<Exercise> get exercises => edges.map((edge) => edge.node).toList();
factory ExerciseConnection.fromJson(Map<String, dynamic> json) {
return ExerciseConnection(
totalCount: json['totalCount'],
pageInfo: PageInfo.fromJson(json['pageInfo']),
edges: (json['edges'] as List)
.map((edge) => ExerciseEdge.fromJson(edge))
.toList(),
);
}
}
class ExerciseEdge {
final String cursor;
final Exercise node;
ExerciseEdge({required this.cursor, required this.node});
factory ExerciseEdge.fromJson(Map<String, dynamic> json) {
return ExerciseEdge(
cursor: json['cursor'],
node: Exercise.fromJson(json['node']),
);
}
}
class PageInfo {
final bool hasNextPage;
final bool hasPreviousPage;
final String? startCursor;
final String? endCursor;
PageInfo({
required this.hasNextPage,
required this.hasPreviousPage,
this.startCursor,
this.endCursor,
});
factory PageInfo.fromJson(Map<String, dynamic> json) {
return PageInfo(
hasNextPage: json['hasNextPage'],
hasPreviousPage: json['hasPreviousPage'],
startCursor: json['startCursor'],
endCursor: json['endCursor'],
);
}
}
3. Usage Examples
- Search Exercises
- Browse Muscle Groups
- Exercise Detail View
- Exercise List with Pagination
// 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(", ")}');
}
// Get all general muscle groups with their specific muscles
final generalGroups = await muscleGroupService.getGeneralMuscleGroups();
for (final group in generalGroups) {
print('\n${group.name}:');
for (final muscle in group.specificMuscleGroups) {
print(' - ${muscle.name} (${muscle.slug})');
}
}
// Filter exercises by specific muscle group
final bicepsExercises = await exerciseService.getExercises(
muscleGroupId: "biceps_brachii_id",
first: 20,
);
class ExerciseDetailWidget extends StatelessWidget {
final String exerciseId;
const ExerciseDetailWidget({Key? key, required this.exerciseId}) : super(key: key);
@override
Widget build(BuildContext context) {
return FutureBuilder<Exercise?>(
future: exerciseService.getExercise(exerciseId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
final exercise = snapshot.data;
if (exercise == null) {
return const Text('Exercise not found');
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
exercise.name,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
if (exercise.level != null) ...[
Chip(label: Text('Level: ${exercise.level}')),
const SizedBox(height: 8),
],
if (exercise.imageUrl != null) ...[
Image.network(
exercise.imageUrl!,
height: 200,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.fitness_center, size: 100),
),
const SizedBox(height: 16),
],
if (exercise.description != null) ...[
Text(
exercise.description!,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
],
if (exercise.tips.isNotEmpty) ...[
Text(
'Tips:',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...exercise.tips.map((tip) =>
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('• $tip'),
),
),
const SizedBox(height: 16),
],
if (exercise.equipmentType != null) ...[
Row(
children: [
Icon(Icons.fitness_center),
const SizedBox(width: 8),
Text('Equipment: ${exercise.equipmentType}'),
],
),
const SizedBox(height: 8),
],
if (exercise.primaryMuscleGroup != null) ...[
Text(
'Primary Muscle: ${exercise.primaryMuscleGroup!.name}',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
],
if (exercise.muscleGroups.isNotEmpty) ...[
Wrap(
spacing: 8,
children: exercise.muscleGroups.map((mg) =>
Chip(
label: Text(mg.name),
avatar: mg.imageName != null
? CircleAvatar(
backgroundImage: AssetImage('assets/muscles/${mg.imageName}'),
)
: null,
),
).toList(),
),
const SizedBox(height: 16),
],
if (exercise.videoUrl != null) ...[
ElevatedButton.icon(
onPressed: () {
// Open video URL - delegate to server-provided URL
// ✅ CORRECT: Use server-provided video URL
launchUrl(Uri.parse(exercise.videoUrl!));
},
icon: const Icon(Icons.play_arrow),
label: const Text('Watch Demo'),
),
],
],
),
);
},
);
}
}
class ExerciseListWidget extends StatefulWidget {
final String? muscleGroupFilter;
final String? searchQuery;
const ExerciseListWidget({
Key? key,
this.muscleGroupFilter,
this.searchQuery,
}) : super(key: key);
@override
_ExerciseListWidgetState createState() => _ExerciseListWidgetState();
}
class _ExerciseListWidgetState extends State<ExerciseListWidget> {
final List<Exercise> _exercises = [];
String? _nextCursor;
bool _hasNextPage = true;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadExercises();
}
@override
void didUpdateWidget(ExerciseListWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.muscleGroupFilter != widget.muscleGroupFilter ||
oldWidget.searchQuery != widget.searchQuery) {
_resetAndLoad();
}
}
void _resetAndLoad() {
setState(() {
_exercises.clear();
_nextCursor = null;
_hasNextPage = true;
});
_loadExercises();
}
Future<void> _loadExercises() async {
if (_isLoading || !_hasNextPage) return;
setState(() => _isLoading = true);
try {
final connection = await exerciseService.getExercises(
first: 20,
after: _nextCursor,
search: widget.searchQuery,
muscleGroupId: widget.muscleGroupFilter,
);
setState(() {
_exercises.addAll(connection.exercises);
_nextCursor = connection.pageInfo.endCursor;
_hasNextPage = connection.pageInfo.hasNextPage;
});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading exercises: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: _exercises.length + (_hasNextPage ? 1 : 0),
itemBuilder: (context, index) {
if (index == _exercises.length) {
_loadExercises();
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final exercise = _exercises[index];
return Card(
child: ListTile(
leading: exercise.imageUrl != null
? Image.network(
exercise.imageUrl!,
width: 50,
height: 50,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.fitness_center),
)
: const Icon(Icons.fitness_center),
title: Text(exercise.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (exercise.description != null)
Text(
exercise.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
if (exercise.level != null) ...[
Icon(Icons.bar_chart, size: 16),
Text(' ${exercise.level}'),
const SizedBox(width: 8),
],
if (exercise.equipmentType != null) ...[
Icon(Icons.fitness_center, size: 16),
Text(' ${exercise.equipmentType}'),
],
],
),
],
),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ExerciseDetailWidget(
exerciseId: exercise.id,
),
),
);
},
),
);
},
);
}
}
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:
- Check the GraphQL schema documentation via GraphiQL
- Review the Exercise and MuscleGroup GraphQL types
- Test queries using the GraphQL Playground
- Contact the backend team for new exercise metadata requirements
Related APIs
- 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