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
- Single Queries
- List All 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
}
}
}
# Get all specific muscle groups
query GetAllMuscleGroups {
muscleGroups {
id
name
generalGroup {
id
name
}
}
}
# Get all general muscle groups with their specific muscles
query GetAllGeneralMuscleGroups {
generalMuscleGroups {
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
- MuscleGroup
- GeneralMuscleGroup
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}';
}
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<dynamic>?)
?.map((mg) => MuscleGroup.fromJson(mg))
.toList() ?? [],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'specificMuscleGroups': specificMuscleGroups.map((mg) => mg.toJson()).toList(),
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is GeneralMuscleGroup && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
@override
String toString() => 'GeneralMuscleGroup{id: $id, name: $name}';
}
3. UI Implementation Examples
- Hierarchical Selector
- Simple Dropdown
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(),
);
},
);
}
}
class MuscleGroupDropdown extends StatefulWidget {
final Function(MuscleGroup?) onChanged;
final MuscleGroup? initialValue;
final String? hintText;
const MuscleGroupDropdown({
Key? key,
required this.onChanged,
this.initialValue,
this.hintText,
}) : super(key: key);
@override
_MuscleGroupDropdownState createState() => _MuscleGroupDropdownState();
}
class _MuscleGroupDropdownState extends State<MuscleGroupDropdown> {
late MuscleGroupService _muscleGroupService;
List<MuscleGroup> _muscleGroups = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_muscleGroupService = MuscleGroupService(GraphQLProvider.of(context).value);
_loadMuscleGroups();
}
Future<void> _loadMuscleGroups() async {
try {
final muscleGroups = await _muscleGroupService.getAllMuscleGroups();
setState(() {
_muscleGroups = muscleGroups;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
// Handle error appropriately
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const DropdownButtonFormField<MuscleGroup>(
items: [],
onChanged: null,
decoration: InputDecoration(
suffixIcon: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return DropdownButtonFormField<MuscleGroup>(
value: widget.initialValue,
hint: Text(widget.hintText ?? 'Select muscle group'),
items: _muscleGroups.map((muscleGroup) {
return DropdownMenuItem<MuscleGroup>(
value: muscleGroup,
child: Text(
'${muscleGroup.generalGroup?.name ?? 'Other'} - ${muscleGroup.name}',
),
);
}).toList(),
onChanged: widget.onChanged,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
);
}
}
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
- For exercise filtering: Use specific
MuscleGroupIDs - For UI organization: Load
GeneralMuscleGroupwith nested specific groups - 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
- Use fragments for repeated field sets
- Cache muscle group data as it changes infrequently
- Batch queries when loading multiple screens
- Preload commonly used data during app startup