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

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

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!
}

Available Operations

Queries

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

Mutations

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

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

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
}

3. UI Implementation Examples

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,
),
);
}
}
}
}

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:

  1. Check the GraphQL schema introspection for latest field definitions
  2. Review the timeline structure examples for proper input formatting
  3. Validate timeline items client-side before sending mutations
  4. Contact the backend team for data model questions