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

MuscleGroup GraphQL API - Flutter Integration Guide

The MuscleGroup API provides access to muscle group data with a two-level hierarchy: General Muscle Groups (categories like "Legs", "Back") and specific Muscle Groups (individual muscles like "Quadriceps", "Latissimus Dorsi"). This guide helps Flutter developers integrate with the MuscleGroup GraphQL endpoints.

GraphQL Schema

Types

MuscleGroup (Specific Muscle)

type MuscleGroup {
id: ID!
name: String!
generalGroup: GeneralMuscleGroup
}

GeneralMuscleGroup (Muscle Category)

type GeneralMuscleGroup {
id: ID!
name: String!
specificMuscleGroups: [MuscleGroup!]!
}

Available Queries

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

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

Flutter Implementation

1. Service Class

import 'package:graphql_flutter/graphql_flutter.dart';

class MuscleGroupService {
final GraphQLClient _client;

MuscleGroupService(this._client);

// Fetch single specific muscle group
Future<MuscleGroup?> getMuscleGroup(String id) async {
const String query = '''
query GetMuscleGroup(\$id: ID!) {
muscleGroup(id: \$id) {
id
name
generalGroup {
id
name
}
}
}
''';

final QueryOptions options = QueryOptions(
document: gql(query),
variables: {'id': id},
);

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

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

final muscleGroupData = result.data?['muscleGroup'];
return muscleGroupData != null ? MuscleGroup.fromJson(muscleGroupData) : null;
}

// Fetch single general muscle group
Future<GeneralMuscleGroup?> getGeneralMuscleGroup(String id) async {
const String query = '''
query GetGeneralMuscleGroup(\$id: ID!) {
generalMuscleGroup(id: \$id) {
id
name
specificMuscleGroups {
id
name
}
}
}
''';

final QueryOptions options = QueryOptions(
document: gql(query),
variables: {'id': id},
);

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

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

final generalGroupData = result.data?['generalMuscleGroup'];
return generalGroupData != null ? GeneralMuscleGroup.fromJson(generalGroupData) : null;
}

// Fetch all specific muscle groups
Future<List<MuscleGroup>> getAllMuscleGroups() async {
const String query = '''
query GetAllMuscleGroups {
muscleGroups {
id
name
generalGroup {
id
name
}
}
}
''';

final QueryOptions options = QueryOptions(
document: gql(query),
fetchPolicy: FetchPolicy.cacheFirst, // Good for relatively static data
);

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

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

final muscleGroupsData = result.data?['muscleGroups'] as List<dynamic>?;
return muscleGroupsData?.map((mg) => MuscleGroup.fromJson(mg)).toList() ?? [];
}

// Fetch all general muscle groups with their specific muscles
Future<List<GeneralMuscleGroup>> getAllGeneralMuscleGroups() async {
const String query = '''
query GetAllGeneralMuscleGroups {
generalMuscleGroups {
id
name
specificMuscleGroups {
id
name
}
}
}
''';

final QueryOptions options = QueryOptions(
document: gql(query),
fetchPolicy: FetchPolicy.cacheFirst, // Good for relatively static data
);

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

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

final generalGroupsData = result.data?['generalMuscleGroups'] as List<dynamic>?;
return generalGroupsData?.map((gg) => GeneralMuscleGroup.fromJson(gg)).toList() ?? [];
}

// Helper method to get muscle groups organized by general category
Future<Map<GeneralMuscleGroup, List<MuscleGroup>>> getMuscleGroupsByCategory() async {
final generalGroups = await getAllGeneralMuscleGroups();
final Map<GeneralMuscleGroup, List<MuscleGroup>> organized = {};

for (final generalGroup in generalGroups) {
organized[generalGroup] = generalGroup.specificMuscleGroups;
}

return organized;
}
}

2. Model Classes

class MuscleGroup {
final String id;
final String name;
final GeneralMuscleGroup? generalGroup;

MuscleGroup({
required this.id,
required this.name,
this.generalGroup,
});

factory MuscleGroup.fromJson(Map<String, dynamic> json) {
return MuscleGroup(
id: json['id'],
name: json['name'],
generalGroup: json['generalGroup'] != null
? GeneralMuscleGroup.fromJson(json['generalGroup'])
: null,
);
}

Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'generalGroup': generalGroup?.toJson(),
};
}

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MuscleGroup && runtimeType == other.runtimeType && id == other.id;

@override
int get hashCode => id.hashCode;

@override
String toString() => 'MuscleGroup{id: $id, name: $name}';
}

3. UI Implementation Examples

class MuscleGroupSelector extends StatefulWidget {
final Function(List<MuscleGroup>) onSelectionChanged;
final List<MuscleGroup> initialSelection;

const MuscleGroupSelector({
Key? key,
required this.onSelectionChanged,
this.initialSelection = const [],
}) : super(key: key);

@override
_MuscleGroupSelectorState createState() => _MuscleGroupSelectorState();
}

class _MuscleGroupSelectorState extends State<MuscleGroupSelector> {
late MuscleGroupService _muscleGroupService;
Map<GeneralMuscleGroup, List<MuscleGroup>> _muscleGroupsByCategory = {};
Set<MuscleGroup> _selectedMuscleGroups = {};
bool _isLoading = true;
String? _error;

@override
void initState() {
super.initState();
_muscleGroupService = MuscleGroupService(GraphQLProvider.of(context).value);
_selectedMuscleGroups = widget.initialSelection.toSet();
_loadMuscleGroups();
}

Future<void> _loadMuscleGroups() async {
try {
final muscleGroupsByCategory = await _muscleGroupService.getMuscleGroupsByCategory();
setState(() {
_muscleGroupsByCategory = muscleGroupsByCategory;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}

void _toggleMuscleGroup(MuscleGroup muscleGroup) {
setState(() {
if (_selectedMuscleGroups.contains(muscleGroup)) {
_selectedMuscleGroups.remove(muscleGroup);
} else {
_selectedMuscleGroups.add(muscleGroup);
}
});
widget.onSelectionChanged(_selectedMuscleGroups.toList());
}

@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}

if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $_error'),
ElevatedButton(
onPressed: _loadMuscleGroups,
child: const Text('Retry'),
),
],
),
);
}

return ListView.builder(
itemCount: _muscleGroupsByCategory.keys.length,
itemBuilder: (context, index) {
final generalGroup = _muscleGroupsByCategory.keys.elementAt(index);
final specificGroups = _muscleGroupsByCategory[generalGroup]!;

return ExpansionTile(
title: Text(
generalGroup.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: specificGroups.map((muscleGroup) {
final isSelected = _selectedMuscleGroups.contains(muscleGroup);

return CheckboxListTile(
title: Text(muscleGroup.name),
value: isSelected,
onChanged: (bool? value) {
_toggleMuscleGroup(muscleGroup);
},
);
}).toList(),
);
},
);
}
}

4. State Management with Provider

class MuscleGroupProvider extends ChangeNotifier {
final MuscleGroupService _muscleGroupService;

MuscleGroupProvider(this._muscleGroupService);

List<GeneralMuscleGroup> _generalMuscleGroups = [];
List<MuscleGroup> _muscleGroups = [];
bool _isLoading = false;
String? _error;

List<GeneralMuscleGroup> get generalMuscleGroups => _generalMuscleGroups;
List<MuscleGroup> get muscleGroups => _muscleGroups;
bool get isLoading => _isLoading;
String? get error => _error;

Future<void> loadMuscleGroups() async {
_isLoading = true;
_error = null;
notifyListeners();

try {
final results = await Future.wait([
_muscleGroupService.getAllGeneralMuscleGroups(),
_muscleGroupService.getAllMuscleGroups(),
]);

_generalMuscleGroups = results[0] as List<GeneralMuscleGroup>;
_muscleGroups = results[1] as List<MuscleGroup>;
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}

List<MuscleGroup> getMuscleGroupsByGeneralCategory(String generalGroupId) {
return _muscleGroups
.where((mg) => mg.generalGroup?.id == generalGroupId)
.toList();
}

MuscleGroup? findMuscleGroupById(String id) {
try {
return _muscleGroups.firstWhere((mg) => mg.id == id);
} catch (e) {
return null;
}
}
}

Data Relationships

Understanding the Hierarchy

GeneralMuscleGroup (e.g., "Legs")
├── MuscleGroup (e.g., "Quadriceps")
├── MuscleGroup (e.g., "Hamstrings")
└── MuscleGroup (e.g., "Calves")

GeneralMuscleGroup (e.g., "Upper Body")
├── MuscleGroup (e.g., "Chest")
├── MuscleGroup (e.g., "Back")
└── MuscleGroup (e.g., "Shoulders")

Query Strategy

  1. For exercise filtering: Use specific MuscleGroup IDs
  2. For UI organization: Load GeneralMuscleGroup with nested specific groups
  3. For dropdown lists: Load all specific muscle groups with their general group context

Best Practices

1. Caching Strategy

Since muscle group data is relatively static, implement aggressive caching:

final GraphQLClient client = GraphQLClient(
link: httpLink,
cache: GraphQLCache(
store: HiveStore(),
defaultPolicies: DefaultPolicies(
query: Policies(
fetch: FetchPolicy.cacheFirst,
cacheReread: CacheRereadPolicy.ignoreAll,
),
),
),
);

// Refresh cache periodically or on app updates
Future<void> refreshMuscleGroupCache() async {
await client.query(QueryOptions(
document: gql(getAllMuscleGroupsQuery),
fetchPolicy: FetchPolicy.networkOnly, // Force network fetch
));
}

2. Error Handling

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

class MuscleGroupNotFoundException extends MuscleGroupException {
MuscleGroupNotFoundException(String message) : super(message);
}

Future<List<MuscleGroup>> getAllMuscleGroupsWithErrorHandling() async {
try {
return await _muscleGroupService.getAllMuscleGroups();
} on OperationException catch (e) {
if (e.graphqlErrors.isNotEmpty) {
throw MuscleGroupException('GraphQL Error: ${e.graphqlErrors.first.message}');
}
if (e.linkException != null) {
throw MuscleGroupException('Network Error: Unable to fetch muscle groups');
}
throw MuscleGroupException('Unknown error occurred');
}
}

3. Testing

// Mock service for testing
class MockMuscleGroupService extends Mock implements MuscleGroupService {}

void main() {
group('MuscleGroupProvider', () {
late MockMuscleGroupService mockService;
late MuscleGroupProvider provider;

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

test('should load muscle groups successfully', () async {
// Arrange
when(() => mockService.getAllGeneralMuscleGroups())
.thenAnswer((_) async => [
GeneralMuscleGroup(id: '1', name: 'Legs', specificMuscleGroups: []),
]);
when(() => mockService.getAllMuscleGroups())
.thenAnswer((_) async => [
MuscleGroup(id: '1', name: 'Quadriceps'),
]);

// Act
await provider.loadMuscleGroups();

// Assert
expect(provider.generalMuscleGroups.length, equals(1));
expect(provider.muscleGroups.length, equals(1));
expect(provider.isLoading, isFalse);
expect(provider.error, isNull);
});
});
}

Performance Tips

  1. Use fragments for repeated field sets
  2. Cache muscle group data as it changes infrequently
  3. Batch queries when loading multiple screens
  4. Preload commonly used data during app startup

Migration Notes

  • Both types use string IDs
  • Relationships are automatically resolved by GraphQL
  • All queries return alphabetically sorted results
  • General muscle groups may have empty specificMuscleGroups arrays if no specific muscles are assigned

Support

For questions about this API:

  1. Check the GraphQL schema introspection
  2. Review the relationship patterns between general and specific muscle groups
  3. Contact the backend team for data structure questions