ProgramWorkoutTemplate GraphQL API
The ProgramWorkoutTemplate API provides comprehensive workout template management through GraphQL. This system enables users to create, manage, and share structured workout templates with timeline-based exercise organization.
Core Capabilities
- Template Creation: Client-friendly timeline-based template creation
- Template Management: Full CRUD operations with ownership validation
- Stock Templates: Pre-built templates available to all users
- Timeline Structure: Support for standalone exercises and exercise groups
- Authorization: User-based ownership and permissions
GraphQL Schema
Core Types
ProgramWorkoutTemplate
type ProgramWorkoutTemplate {
id: ID!
name: String!
level: TrainingLevel!
description: String
durationMinutes: Int!
stock: Boolean!
imgUrl: String
coverImageFileKey: String
coverImageUrl: String
createdAt: DateTime!
modifiedAt: DateTime!
deleted: Boolean!
createdBy: User
trainingFocus: ProgramTrainingFocus!
timelineItems: [WorkoutTimelineItem!]!
}
WorkoutTimelineItem (Union Type)
union WorkoutTimelineItem = StandaloneWorkoutExercise | GroupedWorkoutExercises
type StandaloneWorkoutExercise {
order: Int!
exercise: Exercise!
baseSets: Int!
baseRepsMin: Int!
baseRepsMax: Int!
baseRestSeconds: Int!
}
type GroupedWorkoutExercises {
order: Int!
technique: SetTechnique!
restAfterGroupSeconds: Int!
exercises: [GroupedExercise!]!
}
type GroupedExercise {
orderInGroup: Int!
exercise: Exercise!
baseSets: Int!
baseRepsMin: Int!
baseRepsMax: Int!
baseRestSeconds: Int!
}
enum SetTechnique {
SUPERSET
CIRCUIT
COMPOUND_SET
DROP_SET
}
enum TrainingLevel {
BEGINNER
INTERMEDIATE
ADVANCED
}
Input Types
- Create Template
- Update Template
input CreateProgramWorkoutTemplateInput {
name: String!
level: TrainingLevel!
description: String
durationMinutes: Int!
programTrainingFocusId: ID!
timeline: [TimelineItemInput!]!
}
input TimelineItemInput {
order: Int!
standalone: StandaloneExerciseInput
group: ExerciseGroupInput
}
input StandaloneExerciseInput {
exerciseId: ID!
baseSets: Int!
baseRepsMin: Int!
baseRepsMax: Int!
baseRestSeconds: Int!
}
input ExerciseGroupInput {
technique: SetTechnique!
exercises: [GroupedExerciseInput!]!
}
input GroupedExerciseInput {
exerciseId: ID!
baseSets: Int!
baseRepsMin: Int!
baseRepsMax: Int!
baseRestSeconds: Int!
}
input UpdateProgramWorkoutTemplateInput {
name: String
level: TrainingLevel
description: String
durationMinutes: Int
programTrainingFocusId: ID
coverImageFileKey: String
timeline: [TimelineItemInput!]
}
Available Operations
Queries
- List Templates
- Single Template
query myWorkoutTemplates(
$take: Int = 20
$skip: Int = 0
$includeStock: Boolean = true
) {
myWorkoutTemplates(take: $take, skip: $skip, includeStock: $includeStock) {
id
name
level
description
durationMinutes
stock
coverImageUrl
createdAt
createdBy {
id
name
}
trainingFocus {
id
name
}
timelineItems {
... on StandaloneWorkoutExercise {
order
exercise {
id
name
}
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
... on GroupedWorkoutExercises {
order
technique
restAfterGroupSeconds
exercises {
orderInGroup
exercise {
id
name
}
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
}
}
}
}
Features:
- Returns user's own templates + stock templates
- Pagination support with take/skip
- Optional stock template filtering
- Authorization: Authenticated users only
query myWorkoutTemplate($id: ID!) {
myWorkoutTemplate(id: $id) {
id
name
level
description
durationMinutes
stock
coverImageUrl
createdAt
modifiedAt
createdBy {
id
name
email
}
trainingFocus {
id
name
description
targetRpeMin
targetRpeMax
minReps
maxReps
}
timelineItems {
... on StandaloneWorkoutExercise {
order
exercise {
id
name
description
muscleGroups {
id
name
}
}
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
... on GroupedWorkoutExercises {
order
technique
restAfterGroupSeconds
exercises {
orderInGroup
exercise {
id
name
description
muscleGroups {
id
name
}
}
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
}
}
}
}
Authorization: Template owner or stock template Returns: Single template with full details or null if unauthorized
Mutations
- Create Template
- Update Template
- Delete Template
mutation createWorkoutTemplate($input: CreateProgramWorkoutTemplateInput!) {
createWorkoutTemplate(input: $input) {
id
name
level
description
durationMinutes
createdAt
trainingFocus {
id
name
}
timelineItems {
... on StandaloneWorkoutExercise {
order
exercise { id name }
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
... on GroupedWorkoutExercises {
order
technique
restAfterGroupSeconds
exercises {
orderInGroup
exercise { id name }
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
}
}
}
}
Validations:
- Timeline must contain at least one item
- All exercise IDs must exist in database
- Training focus ID must be valid and enabled
- Template name must be unique for user
mutation updateWorkoutTemplate(
$templateId: ID!
$input: UpdateProgramWorkoutTemplateInput!
) {
updateWorkoutTemplate(templateId: $templateId, input: $input) {
id
name
level
description
durationMinutes
modifiedAt
timelineItems {
... on StandaloneWorkoutExercise {
order
exercise { id name }
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
... on GroupedWorkoutExercises {
order
technique
restAfterGroupSeconds
exercises {
orderInGroup
exercise { id name }
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
}
}
}
}
Authorization: Template owner only (no stock template updates) Partial Updates: All fields optional, modifiedAt auto-updated Timeline Replacement: If timeline provided, completely replaces existing
mutation deleteWorkoutTemplate($templateId: ID!) {
deleteWorkoutTemplate(templateId: $templateId) {
id
name
deleted
modifiedAt
}
}
Authorization: Template owner only Soft Delete: Sets deleted=true, preserves data Returns: Updated template with deletion status
Flutter Implementation
1. Service Class
import 'package:graphql_flutter/graphql_flutter.dart';
class WorkoutTemplateService {
final GraphQLClient _client;
WorkoutTemplateService(this._client);
// Fetch user's workout templates
Future<List<ProgramWorkoutTemplate>> getMyWorkoutTemplates({
int take = 20,
int skip = 0,
bool includeStock = true,
}) async {
const String query = '''
query MyWorkoutTemplates(\$take: Int, \$skip: Int, \$includeStock: Boolean) {
myWorkoutTemplates(take: \$take, skip: \$skip, includeStock: \$includeStock) {
id
name
level
description
durationMinutes
stock
coverImageUrl
createdAt
trainingFocus {
id
name
}
timelineItems {
... on StandaloneWorkoutExercise {
order
exercise {
id
name
}
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
... on GroupedWorkoutExercises {
order
technique
restAfterGroupSeconds
exercises {
orderInGroup
exercise {
id
name
}
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
}
}
}
}
''';
final QueryOptions options = QueryOptions(
document: gql(query),
variables: {
'take': take,
'skip': skip,
'includeStock': includeStock,
},
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw result.exception!;
}
final templatesData = result.data?['myWorkoutTemplates'] as List<dynamic>?;
return templatesData?.map((t) => ProgramWorkoutTemplate.fromJson(t)).toList() ?? [];
}
// Fetch single workout template
Future<ProgramWorkoutTemplate?> getWorkoutTemplate(String id) async {
const String query = '''
query MyWorkoutTemplate(\$id: ID!) {
myWorkoutTemplate(id: \$id) {
id
name
level
description
durationMinutes
stock
coverImageUrl
createdAt
modifiedAt
createdBy {
id
name
}
trainingFocus {
id
name
description
targetRpeMin
targetRpeMax
minReps
maxReps
}
timelineItems {
... on StandaloneWorkoutExercise {
order
exercise {
id
name
description
muscleGroups {
id
name
}
}
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
... on GroupedWorkoutExercises {
order
technique
restAfterGroupSeconds
exercises {
orderInGroup
exercise {
id
name
description
muscleGroups {
id
name
}
}
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
}
}
}
}
''';
final QueryOptions options = QueryOptions(
document: gql(query),
variables: {'id': id},
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw result.exception!;
}
final templateData = result.data?['myWorkoutTemplate'];
return templateData != null ? ProgramWorkoutTemplate.fromJson(templateData) : null;
}
// Create new workout template
Future<ProgramWorkoutTemplate> createWorkoutTemplate(CreateWorkoutTemplateInput input) async {
const String mutation = '''
mutation CreateWorkoutTemplate(\$input: CreateProgramWorkoutTemplateInput!) {
createWorkoutTemplate(input: \$input) {
id
name
level
description
durationMinutes
createdAt
trainingFocus {
id
name
}
timelineItems {
... on StandaloneWorkoutExercise {
order
exercise { id name }
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
... on GroupedWorkoutExercises {
order
technique
restAfterGroupSeconds
exercises {
orderInGroup
exercise { id name }
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
}
}
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'input': input.toJson()},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw result.exception!;
}
return ProgramWorkoutTemplate.fromJson(result.data!['createWorkoutTemplate']);
}
// Update existing workout template
Future<ProgramWorkoutTemplate> updateWorkoutTemplate(
String templateId,
UpdateWorkoutTemplateInput input
) async {
const String mutation = '''
mutation UpdateWorkoutTemplate(\$templateId: ID!, \$input: UpdateProgramWorkoutTemplateInput!) {
updateWorkoutTemplate(templateId: \$templateId, input: \$input) {
id
name
level
description
durationMinutes
modifiedAt
timelineItems {
... on StandaloneWorkoutExercise {
order
exercise { id name }
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
... on GroupedWorkoutExercises {
order
technique
restAfterGroupSeconds
exercises {
orderInGroup
exercise { id name }
baseSets
baseRepsMin
baseRepsMax
baseRestSeconds
}
}
}
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {
'templateId': templateId,
'input': input.toJson(),
},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw result.exception!;
}
return ProgramWorkoutTemplate.fromJson(result.data!['updateWorkoutTemplate']);
}
// Delete workout template
Future<ProgramWorkoutTemplate> deleteWorkoutTemplate(String templateId) async {
const String mutation = '''
mutation DeleteWorkoutTemplate(\$templateId: ID!) {
deleteWorkoutTemplate(templateId: \$templateId) {
id
name
deleted
modifiedAt
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'templateId': templateId},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw result.exception!;
}
return ProgramWorkoutTemplate.fromJson(result.data!['deleteWorkoutTemplate']);
}
}
2. Model Classes
- ProgramWorkoutTemplate
- Timeline Items
- Input Classes
class ProgramWorkoutTemplate {
final String id;
final String name;
final TrainingLevel level;
final String? description;
final int durationMinutes;
final bool stock;
final String? coverImageUrl;
final DateTime createdAt;
final DateTime modifiedAt;
final bool deleted;
final User? createdBy;
final ProgramTrainingFocus trainingFocus;
final List<WorkoutTimelineItem> timelineItems;
ProgramWorkoutTemplate({
required this.id,
required this.name,
required this.level,
this.description,
required this.durationMinutes,
required this.stock,
this.coverImageUrl,
required this.createdAt,
required this.modifiedAt,
required this.deleted,
this.createdBy,
required this.trainingFocus,
required this.timelineItems,
});
factory ProgramWorkoutTemplate.fromJson(Map<String, dynamic> json) {
return ProgramWorkoutTemplate(
id: json['id'],
name: json['name'],
level: TrainingLevel.values.byName(json['level']),
description: json['description'],
durationMinutes: json['durationMinutes'],
stock: json['stock'],
coverImageUrl: json['coverImageUrl'],
createdAt: DateTime.parse(json['createdAt']),
modifiedAt: DateTime.parse(json['modifiedAt']),
deleted: json['deleted'],
createdBy: json['createdBy'] != null ? User.fromJson(json['createdBy']) : null,
trainingFocus: ProgramTrainingFocus.fromJson(json['trainingFocus']),
timelineItems: (json['timelineItems'] as List)
.map((item) => WorkoutTimelineItem.fromJson(item))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'level': level.name,
'description': description,
'durationMinutes': durationMinutes,
'stock': stock,
'coverImageUrl': coverImageUrl,
'createdAt': createdAt.toIso8601String(),
'modifiedAt': modifiedAt.toIso8601String(),
'deleted': deleted,
'createdBy': createdBy?.toJson(),
'trainingFocus': trainingFocus.toJson(),
'timelineItems': timelineItems.map((item) => item.toJson()).toList(),
};
}
}
enum TrainingLevel {
BEGINNER,
INTERMEDIATE,
ADVANCED
}
enum SetTechnique {
SUPERSET,
CIRCUIT,
COMPOUND_SET,
DROP_SET
}
abstract class WorkoutTimelineItem {
final int order;
WorkoutTimelineItem({required this.order});
factory WorkoutTimelineItem.fromJson(Map<String, dynamic> json) {
// Determine type based on presence of fields
if (json.containsKey('exercise') && json['exercise'] != null) {
return StandaloneWorkoutExercise.fromJson(json);
} else if (json.containsKey('exercises') && json['exercises'] != null) {
return GroupedWorkoutExercises.fromJson(json);
} else {
throw ArgumentError('Unknown timeline item type: $json');
}
}
Map<String, dynamic> toJson();
}
class StandaloneWorkoutExercise extends WorkoutTimelineItem {
final Exercise exercise;
final int baseSets;
final int baseRepsMin;
final int baseRepsMax;
final int baseRestSeconds;
StandaloneWorkoutExercise({
required int order,
required this.exercise,
required this.baseSets,
required this.baseRepsMin,
required this.baseRepsMax,
required this.baseRestSeconds,
}) : super(order: order);
factory StandaloneWorkoutExercise.fromJson(Map<String, dynamic> json) {
return StandaloneWorkoutExercise(
order: json['order'],
exercise: Exercise.fromJson(json['exercise']),
baseSets: json['baseSets'],
baseRepsMin: json['baseRepsMin'],
baseRepsMax: json['baseRepsMax'],
baseRestSeconds: json['baseRestSeconds'],
);
}
@override
Map<String, dynamic> toJson() {
return {
'order': order,
'exercise': exercise.toJson(),
'baseSets': baseSets,
'baseRepsMin': baseRepsMin,
'baseRepsMax': baseRepsMax,
'baseRestSeconds': baseRestSeconds,
};
}
}
class GroupedWorkoutExercises extends WorkoutTimelineItem {
final SetTechnique technique;
final int restAfterGroupSeconds;
final List<GroupedExercise> exercises;
GroupedWorkoutExercises({
required int order,
required this.technique,
required this.restAfterGroupSeconds,
required this.exercises,
}) : super(order: order);
factory GroupedWorkoutExercises.fromJson(Map<String, dynamic> json) {
return GroupedWorkoutExercises(
order: json['order'],
technique: SetTechnique.values.byName(json['technique']),
restAfterGroupSeconds: json['restAfterGroupSeconds'],
exercises: (json['exercises'] as List)
.map((e) => GroupedExercise.fromJson(e))
.toList(),
);
}
@override
Map<String, dynamic> toJson() {
return {
'order': order,
'technique': technique.name,
'restAfterGroupSeconds': restAfterGroupSeconds,
'exercises': exercises.map((e) => e.toJson()).toList(),
};
}
}
class GroupedExercise {
final int orderInGroup;
final Exercise exercise;
final int baseSets;
final int baseRepsMin;
final int baseRepsMax;
final int baseRestSeconds;
GroupedExercise({
required this.orderInGroup,
required this.exercise,
required this.baseSets,
required this.baseRepsMin,
required this.baseRepsMax,
required this.baseRestSeconds,
});
factory GroupedExercise.fromJson(Map<String, dynamic> json) {
return GroupedExercise(
orderInGroup: json['orderInGroup'],
exercise: Exercise.fromJson(json['exercise']),
baseSets: json['baseSets'],
baseRepsMin: json['baseRepsMin'],
baseRepsMax: json['baseRepsMax'],
baseRestSeconds: json['baseRestSeconds'],
);
}
Map<String, dynamic> toJson() {
return {
'orderInGroup': orderInGroup,
'exercise': exercise.toJson(),
'baseSets': baseSets,
'baseRepsMin': baseRepsMin,
'baseRepsMax': baseRepsMax,
'baseRestSeconds': baseRestSeconds,
};
}
}
class CreateWorkoutTemplateInput {
final String name;
final TrainingLevel level;
final String? description;
final int durationMinutes;
final String programTrainingFocusId;
final List<TimelineItemInput> timeline;
CreateWorkoutTemplateInput({
required this.name,
required this.level,
this.description,
required this.durationMinutes,
required this.programTrainingFocusId,
required this.timeline,
});
Map<String, dynamic> toJson() {
return {
'name': name,
'level': level.name,
'description': description,
'durationMinutes': durationMinutes,
'programTrainingFocusId': programTrainingFocusId,
'timeline': timeline.map((item) => item.toJson()).toList(),
};
}
}
class UpdateWorkoutTemplateInput {
final String? name;
final TrainingLevel? level;
final String? description;
final int? durationMinutes;
final String? programTrainingFocusId;
final String? coverImageFileKey;
final List<TimelineItemInput>? timeline;
UpdateWorkoutTemplateInput({
this.name,
this.level,
this.description,
this.durationMinutes,
this.programTrainingFocusId,
this.coverImageFileKey,
this.timeline,
});
Map<String, dynamic> toJson() {
final Map<String, dynamic> json = {};
if (name != null) json['name'] = name;
if (level != null) json['level'] = level!.name;
if (description != null) json['description'] = description;
if (durationMinutes != null) json['durationMinutes'] = durationMinutes;
if (programTrainingFocusId != null) json['programTrainingFocusId'] = programTrainingFocusId;
if (coverImageFileKey != null) json['coverImageFileKey'] = coverImageFileKey;
if (timeline != null) json['timeline'] = timeline!.map((item) => item.toJson()).toList();
return json;
}
}
class TimelineItemInput {
final int order;
final StandaloneExerciseInput? standalone;
final ExerciseGroupInput? group;
TimelineItemInput({
required this.order,
this.standalone,
this.group,
}) : assert(
(standalone != null) ^ (group != null),
'Timeline item must have exactly one of standalone or group'
);
Map<String, dynamic> toJson() {
final Map<String, dynamic> json = {'order': order};
if (standalone != null) {
json['standalone'] = standalone!.toJson();
}
if (group != null) {
json['group'] = group!.toJson();
}
return json;
}
}
class StandaloneExerciseInput {
final String exerciseId;
final int baseSets;
final int baseRepsMin;
final int baseRepsMax;
final int baseRestSeconds;
StandaloneExerciseInput({
required this.exerciseId,
required this.baseSets,
required this.baseRepsMin,
required this.baseRepsMax,
required this.baseRestSeconds,
});
Map<String, dynamic> toJson() {
return {
'exerciseId': exerciseId,
'baseSets': baseSets,
'baseRepsMin': baseRepsMin,
'baseRepsMax': baseRepsMax,
'baseRestSeconds': baseRestSeconds,
};
}
}
class ExerciseGroupInput {
final SetTechnique technique;
final List<GroupedExerciseInput> exercises;
ExerciseGroupInput({
required this.technique,
required this.exercises,
});
Map<String, dynamic> toJson() {
return {
'technique': technique.name,
'exercises': exercises.map((e) => e.toJson()).toList(),
};
}
}
class GroupedExerciseInput {
final String exerciseId;
final int baseSets;
final int baseRepsMin;
final int baseRepsMax;
final int baseRestSeconds;
GroupedExerciseInput({
required this.exerciseId,
required this.baseSets,
required this.baseRepsMin,
required this.baseRepsMax,
required this.baseRestSeconds,
});
Map<String, dynamic> toJson() {
return {
'exerciseId': exerciseId,
'baseSets': baseSets,
'baseRepsMin': baseRepsMin,
'baseRepsMax': baseRepsMax,
'baseRestSeconds': baseRestSeconds,
};
}
}
3. UI Implementation Examples
- Template List Widget
- Template Detail View
class WorkoutTemplateListWidget extends StatefulWidget {
@override
_WorkoutTemplateListWidgetState createState() => _WorkoutTemplateListWidgetState();
}
class _WorkoutTemplateListWidgetState extends State<WorkoutTemplateListWidget> {
late WorkoutTemplateService _templateService;
List<ProgramWorkoutTemplate> _templates = [];
bool _isLoading = true;
String? _error;
bool _includeStock = true;
@override
void initState() {
super.initState();
_templateService = WorkoutTemplateService(GraphQLProvider.of(context).value);
_loadTemplates();
}
Future<void> _loadTemplates() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final templates = await _templateService.getMyWorkoutTemplates(
includeStock: _includeStock,
);
setState(() {
_templates = templates;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Workout Templates'),
actions: [
IconButton(
icon: Icon(_includeStock ? Icons.star : Icons.star_border),
onPressed: () {
setState(() => _includeStock = !_includeStock);
_loadTemplates();
},
tooltip: 'Toggle stock templates',
),
],
),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () => _navigateToCreateTemplate(),
child: const Icon(Icons.add),
tooltip: 'Create Template',
),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Error: $_error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadTemplates,
child: const Text('Retry'),
),
],
),
);
}
if (_templates.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.fitness_center, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
'No workout templates found',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Create your first template to get started',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
return ListView.builder(
itemCount: _templates.length,
itemBuilder: (context, index) {
final template = _templates[index];
return _buildTemplateCard(template);
},
);
}
Widget _buildTemplateCard(ProgramWorkoutTemplate template) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getLevelColor(template.level),
child: Text(
template.level.name[0],
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
title: Text(
template.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (template.description != null)
Text(template.description!),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.timer, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text('${template.durationMinutes} min'),
const SizedBox(width: 16),
Icon(Icons.fitness_center, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text('${template.timelineItems.length} exercises'),
if (template.stock) ...[
const SizedBox(width: 16),
Icon(Icons.star, size: 16, color: Colors.amber),
const SizedBox(width: 4),
const Text('Stock'),
],
],
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (value) => _handleMenuAction(value, template),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'view',
child: ListTile(
leading: Icon(Icons.visibility),
title: Text('View'),
dense: true,
),
),
if (!template.stock) ...[
const PopupMenuItem(
value: 'edit',
child: ListTile(
leading: Icon(Icons.edit),
title: Text('Edit'),
dense: true,
),
),
const PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete, color: Colors.red),
title: Text('Delete', style: TextStyle(color: Colors.red)),
dense: true,
),
),
],
],
),
onTap: () => _navigateToTemplateDetail(template),
),
);
}
Color _getLevelColor(TrainingLevel level) {
switch (level) {
case TrainingLevel.BEGINNER:
return Colors.green;
case TrainingLevel.INTERMEDIATE:
return Colors.orange;
case TrainingLevel.ADVANCED:
return Colors.red;
}
}
void _handleMenuAction(String action, ProgramWorkoutTemplate template) {
switch (action) {
case 'view':
_navigateToTemplateDetail(template);
break;
case 'edit':
_navigateToEditTemplate(template);
break;
case 'delete':
_showDeleteConfirmation(template);
break;
}
}
void _navigateToCreateTemplate() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => CreateWorkoutTemplateScreen(),
),
).then((_) => _loadTemplates()); // Refresh list after creation
}
void _navigateToTemplateDetail(ProgramWorkoutTemplate template) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutTemplateDetailScreen(template: template),
),
);
}
void _navigateToEditTemplate(ProgramWorkoutTemplate template) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EditWorkoutTemplateScreen(template: template),
),
).then((_) => _loadTemplates()); // Refresh list after edit
}
Future<void> _showDeleteConfirmation(ProgramWorkoutTemplate template) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Template'),
content: Text('Are you sure you want to delete "${template.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
await _templateService.deleteWorkoutTemplate(template.id);
_loadTemplates(); // Refresh list
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Template "${template.name}" deleted')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to delete template: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
class WorkoutTemplateDetailScreen extends StatelessWidget {
final ProgramWorkoutTemplate template;
const WorkoutTemplateDetailScreen({
Key? key,
required this.template,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(template.name),
actions: [
if (!template.stock)
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _navigateToEdit(context),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeaderCard(),
const SizedBox(height: 16),
_buildTrainingFocusCard(),
const SizedBox(height: 16),
_buildTimelineCard(),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _startWorkout(context),
icon: const Icon(Icons.play_arrow),
label: const Text('Start Workout'),
),
);
}
Widget _buildHeaderCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: _getLevelColor(template.level),
child: Text(
template.level.name[0],
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
template.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
template.level.name,
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
],
),
),
if (template.stock)
const Icon(Icons.star, color: Colors.amber),
],
),
if (template.description != null) ...[
const SizedBox(height: 12),
Text(template.description!),
],
const SizedBox(height: 12),
Row(
children: [
_buildInfoChip(
Icons.timer,
'${template.durationMinutes} min',
),
const SizedBox(width: 8),
_buildInfoChip(
Icons.fitness_center,
'${template.timelineItems.length} exercises',
),
],
),
],
),
),
);
}
Widget _buildInfoChip(IconData icon, String label) {
return Chip(
avatar: Icon(icon, size: 16),
label: Text(label),
backgroundColor: Colors.grey[100],
);
}
Widget _buildTrainingFocusCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Training Focus',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
template.trainingFocus.name,
style: const TextStyle(fontSize: 16),
),
if (template.trainingFocus.description != null) ...[
const SizedBox(height: 4),
Text(
template.trainingFocus.description!,
style: TextStyle(color: Colors.grey[600]),
),
],
const SizedBox(height: 8),
Row(
children: [
Text('Reps: ${template.trainingFocus.minReps}-${template.trainingFocus.maxReps}'),
const SizedBox(width: 16),
Text('RPE: ${template.trainingFocus.targetRpeMin}-${template.trainingFocus.targetRpeMax}'),
],
),
],
),
),
);
}
Widget _buildTimelineCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Workout Timeline',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
...template.timelineItems.map((item) => _buildTimelineItem(item)),
],
),
),
);
}
Widget _buildTimelineItem(WorkoutTimelineItem item) {
if (item is StandaloneWorkoutExercise) {
return _buildStandaloneExercise(item);
} else if (item is GroupedWorkoutExercises) {
return _buildGroupedExercises(item);
} else {
return const SizedBox.shrink();
}
}
Widget _buildStandaloneExercise(StandaloneWorkoutExercise exercise) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: Colors.blue,
radius: 16,
child: Text(
'${exercise.order + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
exercise.exercise.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'${exercise.baseSets} sets × ${exercise.baseRepsMin}-${exercise.baseRepsMax} reps',
style: TextStyle(color: Colors.grey[600]),
),
Text(
'Rest: ${exercise.baseRestSeconds}s',
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
],
),
);
}
Widget _buildGroupedExercises(GroupedWorkoutExercises group) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.orange[300]!),
borderRadius: BorderRadius.circular(8),
color: Colors.orange[50],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.orange,
radius: 16,
child: Text(
'${group.order + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Text(
group.technique.name.replaceAll('_', ' '),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const Spacer(),
Text(
'Rest: ${group.restAfterGroupSeconds}s',
style: TextStyle(color: Colors.grey[600]),
),
],
),
const SizedBox(height: 8),
...group.exercises.map((exercise) => Padding(
padding: const EdgeInsets.only(left: 40, bottom: 4),
child: Row(
children: [
Text('${exercise.orderInGroup + 1}.'),
const SizedBox(width: 8),
Expanded(
child: Text(
exercise.exercise.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Text(
'${exercise.baseSets} × ${exercise.baseRepsMin}-${exercise.baseRepsMax}',
style: TextStyle(color: Colors.grey[600]),
),
],
),
)),
],
),
);
}
Color _getLevelColor(TrainingLevel level) {
switch (level) {
case TrainingLevel.BEGINNER:
return Colors.green;
case TrainingLevel.INTERMEDIATE:
return Colors.orange;
case TrainingLevel.ADVANCED:
return Colors.red;
}
}
void _navigateToEdit(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EditWorkoutTemplateScreen(template: template),
),
);
}
void _startWorkout(BuildContext context) {
// Navigate to workout session screen
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutSessionScreen(template: template),
),
);
}
}
Business Logic
Template Ownership Model
class TemplateAccessControl {
static bool canRead(ProgramWorkoutTemplate template, User user) {
return template.stock || template.createdBy?.id == user.id;
}
static bool canWrite(ProgramWorkoutTemplate template, User user) {
return !template.stock && template.createdBy?.id == user.id;
}
static bool canDelete(ProgramWorkoutTemplate template, User user) {
return !template.stock && template.createdBy?.id == user.id;
}
}
Timeline Validation
class TimelineValidator {
static void validateTimeline(List<TimelineItemInput> timeline) {
if (timeline.isEmpty) {
throw ValidationException('A template must have at least one item in its timeline.');
}
// Validate order sequence
final orders = timeline.map((item) => item.order).toSet();
for (int i = 0; i < timeline.length; i++) {
if (!orders.contains(i)) {
throw ValidationException('Timeline order values must be sequential starting from 0.');
}
}
// Validate each item
for (final item in timeline) {
validateTimelineItem(item);
}
}
static void validateTimelineItem(TimelineItemInput item) {
final hasStandalone = item.standalone != null;
final hasGroup = item.group != null;
if (hasStandalone == hasGroup) {
throw ValidationException(
'Timeline item at order ${item.order} must have exactly one of "standalone" or "group".'
);
}
if (hasGroup && item.group!.exercises.isEmpty) {
throw ValidationException(
'Exercise group at order ${item.order} must contain at least one exercise.'
);
}
}
}
class ValidationException implements Exception {
final String message;
ValidationException(this.message);
@override
String toString() => message;
}
Error Handling
Common Error Scenarios
class WorkoutTemplateException implements Exception {
final String message;
final String code;
final String? field;
WorkoutTemplateException(this.message, this.code, [this.field]);
@override
String toString() => message;
}
class WorkoutTemplateNotFoundException extends WorkoutTemplateException {
WorkoutTemplateNotFoundException(String templateId)
: super('Template not found: $templateId', 'NOT_FOUND');
}
class UnauthorizedTemplateAccessException extends WorkoutTemplateException {
UnauthorizedTemplateAccessException()
: super('You do not have permission to access this template', 'FORBIDDEN');
}
class DuplicateTemplateNameException extends WorkoutTemplateException {
DuplicateTemplateNameException(String name)
: super('A workout template with the name "$name" already exists', 'CONFLICT', 'name');
}
class InvalidExerciseException extends WorkoutTemplateException {
InvalidExerciseException(List<String> invalidIds)
: super('The following exercise IDs were not found: ${invalidIds.join(", ")}', 'BAD_USER_INPUT', 'timeline');
}
Error Handling in Service
Future<ProgramWorkoutTemplate> createWorkoutTemplateWithErrorHandling(
CreateWorkoutTemplateInput input
) async {
try {
return await _templateService.createWorkoutTemplate(input);
} on OperationException catch (e) {
if (e.graphqlErrors.isNotEmpty) {
final error = e.graphqlErrors.first;
final code = error.extensions?['code'] as String?;
final field = error.extensions?['field'] as String?;
switch (code) {
case 'CONFLICT':
if (field == 'name') {
throw DuplicateTemplateNameException(input.name);
}
break;
case 'BAD_USER_INPUT':
if (field == 'timeline') {
throw WorkoutTemplateException(error.message, code, field);
}
break;
case 'FORBIDDEN':
throw UnauthorizedTemplateAccessException();
default:
throw WorkoutTemplateException(error.message, code ?? 'UNKNOWN_ERROR');
}
}
throw WorkoutTemplateException('Network error occurred', 'NETWORK_ERROR');
}
}
Best Practices
1. Template Management
class WorkoutTemplateManager {
final WorkoutTemplateService _service;
final Map<String, ProgramWorkoutTemplate> _cache = {};
WorkoutTemplateManager(this._service);
// Cache frequently accessed templates
Future<ProgramWorkoutTemplate?> getTemplate(String id) async {
if (_cache.containsKey(id)) {
return _cache[id];
}
final template = await _service.getWorkoutTemplate(id);
if (template != null) {
_cache[id] = template;
}
return template;
}
// Clear cache when templates are modified
void invalidateCache(String templateId) {
_cache.remove(templateId);
}
// Validate template before creation
void validateTemplate(CreateWorkoutTemplateInput input) {
TimelineValidator.validateTimeline(input.timeline);
if (input.durationMinutes <= 0) {
throw ValidationException('Duration must be positive');
}
if (input.name.trim().isEmpty) {
throw ValidationException('Template name is required');
}
}
}
2. State Management
class WorkoutTemplateProvider extends ChangeNotifier {
final WorkoutTemplateService _service;
List<ProgramWorkoutTemplate> _templates = [];
bool _isLoading = false;
String? _error;
WorkoutTemplateProvider(this._service);
List<ProgramWorkoutTemplate> get templates => _templates;
bool get isLoading => _isLoading;
String? get error => _error;
Future<void> loadTemplates({bool includeStock = true}) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_templates = await _service.getMyWorkoutTemplates(includeStock: includeStock);
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> createTemplate(CreateWorkoutTemplateInput input) async {
try {
final newTemplate = await _service.createWorkoutTemplate(input);
_templates.insert(0, newTemplate);
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
rethrow;
}
}
Future<void> deleteTemplate(String templateId) async {
try {
await _service.deleteWorkoutTemplate(templateId);
_templates.removeWhere((t) => t.id == templateId);
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
rethrow;
}
}
}
3. Testing
void main() {
group('WorkoutTemplateService', () {
late MockGraphQLClient mockClient;
late WorkoutTemplateService service;
setUp(() {
mockClient = MockGraphQLClient();
service = WorkoutTemplateService(mockClient);
});
test('should create template with standalone exercise', () async {
// Arrange
final input = CreateWorkoutTemplateInput(
name: 'Test Template',
level: TrainingLevel.BEGINNER,
durationMinutes: 45,
programTrainingFocusId: 'focus123',
timeline: [
TimelineItemInput(
order: 0,
standalone: StandaloneExerciseInput(
exerciseId: 'exercise123',
baseSets: 3,
baseRepsMin: 8,
baseRepsMax: 12,
baseRestSeconds: 60,
),
),
],
);
when(() => mockClient.mutate(any())).thenAnswer((_) async => QueryResult(
data: {
'createWorkoutTemplate': {
'id': 'template123',
'name': 'Test Template',
// ... mock response data
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Act
final result = await service.createWorkoutTemplate(input);
// Assert
expect(result.id, equals('template123'));
expect(result.name, equals('Test Template'));
});
test('should handle validation errors', () async {
// Arrange
final input = CreateWorkoutTemplateInput(
name: 'Invalid Template',
level: TrainingLevel.BEGINNER,
durationMinutes: 45,
programTrainingFocusId: 'focus123',
timeline: [], // Empty timeline should cause validation error
);
when(() => mockClient.mutate(any())).thenAnswer((_) async => QueryResult(
data: null,
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
exception: OperationException(
graphqlErrors: [
GraphQLError(
message: 'A template must have at least one item in its timeline.',
extensions: {'code': 'BAD_USER_INPUT', 'field': 'timeline'},
),
],
),
));
// Act & Assert
expect(
() => service.createWorkoutTemplate(input),
throwsA(isA<OperationException>()),
);
});
});
}
Migration Notes
- All template IDs are ObjectId strings
- Timeline items use union type requiring proper type discrimination
- Stock templates are read-only and owned by system
- Soft delete preserves template data for recovery
- Timeline order must be sequential starting from 0
Support
For questions about this API:
- Check the GraphQL schema introspection for latest field definitions
- Review the timeline structure examples for proper input formatting
- Validate timeline items client-side before sending mutations
- Contact the backend team for data model questions