Program User Instances API
The Program User Instances API manages user enrollment in fitness programs, workout scheduling, progression tracking, and exercise customization. This system creates individualized program instances for users with their preferred training days and custom progression settings.
⚠️ CRITICAL: THIN CLIENT IMPLEMENTATION ONLY
What Flutter SHOULD do:
- ✅ Display program enrollment UI and enrollment forms
- ✅ Show weekly workout schedules and program progress
- ✅ Handle workout start/completion user interactions
- ✅ Cache program instance data for offline viewing
- ✅ Display workout templates and exercise details
What Flutter MUST NOT do:
- ❌ Calculate workout scheduling logic
- ❌ Determine program progression rules
- ❌ Implement workout completion validation
- ❌ Calculate catch-up workout schedules
- ❌ Process exercise playbook assignments
Core Concepts
Program User Instance
A Program User Instance (PUI) represents a user's enrollment in a specific fitness program. Each instance contains:
- Program Snapshot: Immutable copy of the program at enrollment time
- Scheduling Preferences: User's preferred training days and timing
- Progression State: Current week, completed workouts, and advancement
- Exercise Customization: Custom playbook assignments for exercises
- Status Management: Active, paused, completed, or ended states
Workout Scheduling System
The system automatically generates weekly workout schedules based on:
- Program Type: Linear (progressive) vs Cycling (repeating templates)
- Training Days: User's preferred days of the week
- Current Progress: Which week and workout order the user is on
- Catch-up Logic: Automatic rescheduling of missed workouts
Program Progression
Users advance through programs via:
- Workout Completion: Marking scheduled workouts as completed
- Automatic Advancement: Moving to next workout in sequence
- Week Progression: Advancing to new program weeks
- Catch-up Scheduling: Handling missed workout recovery
GraphQL Schema
Types
type ProgramUserInstance {
id: ID!
status: UserProgramInstanceStatus!
startDate: DateTime!
expectedProgramEndDate: DateTime!
actualProgramEndDate: DateTime
currentPlanWeekNumber: Int!
lastCompletedPwtOrderInWeek: Int
lastActivityDate: DateTime
# Relations
user: User!
programPlan: ProgramPlan!
# Schedule and preferences
preferredTrainingDays: [Int]
weeklySchedule(year: Int!, isoWeek: Int!): [UserProgramScheduledWeekWorkout!]!
# Program snapshot data
programSnapshot: ProgramSnapshot
}
type UserProgramScheduledWeekWorkout {
id: ID!
year: Int!
isoWeek: Int!
dayOfWeek: Int!
planWeekNumber: Int!
status: ScheduledWorkoutStatus!
completedWorkoutHistoryId: ID
# Workout details
masterPwtIdForSchedule: ID!
workoutTemplateName: String
workoutTemplate: ProgramWorkoutTemplate
# Schedule analysis
scheduledDate: DateTime!
isCatchUp: Boolean!
originalWeekNumber: Int
}
type ProgramSnapshot {
masterProgramPlanId: ID!
nameAtEnrollment: String!
versionAtEnrollment: String!
descriptionAtEnrollment: String
durationWeeksAtEnrollment: Int!
createdAt: DateTime!
pwtSnapshots: [ProgramWorkoutTemplateSnapshot!]!
}
type ProgramWorkoutTemplateSnapshot {
masterPwtId: ID!
orderInPlanSnapshot: Int!
nameAtEnrollment: String!
descriptionAtEnrollment: String
durationMinutesAtEnrollment: Int!
exerciseSnapshots: [ProgramExerciseSnapshot!]!
}
type ProgramExerciseSnapshot {
masterExerciseId: ID!
orderInPwtSnapshot: Int!
groupOrderAtEnrollment: Int
setsAtEnrollment: Int!
repsMinAtEnrollment: Int!
repsMaxAtEnrollment: Int
restSecondsAtEnrollment: Int!
originalRepsAtEnrollment: Int!
baseTrainingWeight: Float
progressionPlaybookId: ID
progressionState: JSON
}
enum UserProgramInstanceStatus {
ACTIVE
PAUSED
COMPLETED
ENDED_BY_USER
}
enum ScheduledWorkoutStatus {
SCHEDULED
RUNNING
COMPLETED
SKIPPED
MISSED_SYSTEM
}
Input Types
input EnrollInProgramInput {
programPlanId: ID!
preferredTrainingDays: [DayOfWeek]
playbookConfiguration: PlaybookConfigurationInput
}
input PlaybookConfigurationInput {
defaultPlaybookId: ID
exerciseOverrides: [ExercisePlaybookOverrideInput]
}
input ExercisePlaybookOverrideInput {
exerciseId: ID!
playbookId: ID!
}
input UpdatePreferredDaysInput {
programUserInstanceId: ID!
preferredTrainingDays: [DayOfWeek]
}
input ProgramUserInstanceIdInput {
programUserInstanceId: ID!
}
input StartScheduledWorkoutInput {
scheduledWorkoutId: ID!
}
input CompleteScheduledWorkoutInput {
scheduledWorkoutId: ID!
workoutHistoryEntryId: ID!
}
input SkipScheduledWorkoutInput {
scheduledWorkoutId: ID!
}
input UpdateExercisePlaybookInput {
programUserInstanceId: ID!
exerciseId: ID!
newPlaybookId: ID!
}
type DeletionResponse {
success: Boolean!
message: String!
}
Queries
Get Active Program
Retrieve the user's currently active or paused program instance.
query ActiveProgram {
activeProgram {
id
status
startDate
expectedProgramEndDate
currentPlanWeekNumber
lastCompletedPwtOrderInWeek
preferredTrainingDays
programPlan {
id
name
description
level
goal
durationWeeks
daysPerWeek
}
programSnapshot {
nameAtEnrollment
versionAtEnrollment
durationWeeksAtEnrollment
pwtSnapshots {
masterPwtId
orderInPlanSnapshot
nameAtEnrollment
durationMinutesAtEnrollment
}
}
}
}
Get Weekly Schedule
Retrieve workout schedule for a specific week.
query WeeklySchedule($puiId: ID!, $year: Int!, $isoWeek: Int!) {
activeProgram {
id
weeklySchedule(year: $year, isoWeek: $isoWeek) {
id
dayOfWeek
planWeekNumber
status
scheduledDate
isCatchUp
originalWeekNumber
workoutTemplateName
workoutTemplate {
id
name
description
durationMinutes
exercises {
id
name
sets
repsMin
repsMax
restSeconds
}
}
}
}
}
Mutations
Enroll in Program
Enroll a user in a fitness program with custom preferences and playbook configuration.
- GraphQL Mutation
- Variables
- Response
mutation EnrollInProgram($input: EnrollInProgramInput!) {
enrollInProgram(input: $input) {
id
status
startDate
expectedProgramEndDate
currentPlanWeekNumber
preferredTrainingDays
programPlan {
id
name
description
level
goal
durationWeeks
daysPerWeek
}
programSnapshot {
nameAtEnrollment
versionAtEnrollment
durationWeeksAtEnrollment
}
}
}
{
"input": {
"programPlanId": "60f0cf0b2f8fb814a8a3d123",
"preferredTrainingDays": ["MONDAY", "WEDNESDAY", "FRIDAY"],
"playbookConfiguration": {
"defaultPlaybookId": "60f0cf0b2f8fb814a8a3d456",
"exerciseOverrides": [
{
"exerciseId": "60f0cf0b2f8fb814a8a3d789",
"playbookId": "60f0cf0b2f8fb814a8a3d987"
}
]
}
}
}
{
"data": {
"enrollInProgram": {
"id": "60f0cf0b2f8fb814a8a3d321",
"status": "ACTIVE",
"startDate": "2023-12-01T00:00:00.000Z",
"expectedProgramEndDate": "2024-03-01T00:00:00.000Z",
"currentPlanWeekNumber": 1,
"preferredTrainingDays": [1, 3, 5],
"programPlan": {
"id": "60f0cf0b2f8fb814a8a3d123",
"name": "Intermediate PPL Program",
"description": "A comprehensive push-pull-legs routine for intermediate lifters",
"level": "INTERMEDIATE",
"goal": "MUSCLE_GAIN",
"durationWeeks": 12,
"daysPerWeek": 6
},
"programSnapshot": {
"nameAtEnrollment": "Intermediate PPL Program",
"versionAtEnrollment": "1.2",
"durationWeeksAtEnrollment": 12
}
}
}
}
Program State Management
Pause Program
mutation PauseProgram($input: ProgramUserInstanceIdInput!) {
pauseProgram(input: $input) {
id
status
lastActivityDate
}
}
Resume Program
mutation ResumeProgram($input: ProgramUserInstanceIdInput!) {
resumeProgram(input: $input) {
id
status
currentPlanWeekNumber
expectedProgramEndDate
}
}
End Program
mutation EndProgramByUser($input: ProgramUserInstanceIdInput!) {
endProgramByUser(input: $input) {
id
status
actualProgramEndDate
}
}
Delete Program Instance
mutation DeleteProgramUserInstance($input: ProgramUserInstanceIdInput!) {
deleteProgramUserInstance(input: $input) {
success
message
}
}
Update Preferred Training Days
Change user's preferred training days and regenerate schedule.
- GraphQL Mutation
- Variables
- Response
mutation UpdatePreferredTrainingDays($input: UpdatePreferredDaysInput!) {
updatePreferredTrainingDays(input: $input) {
id
preferredTrainingDays
currentPlanWeekNumber
# Check updated schedule
weeklySchedule(year: 2023, isoWeek: 49) {
id
dayOfWeek
scheduledDate
status
workoutTemplateName
}
}
}
{
"input": {
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d321",
"preferredTrainingDays": ["TUESDAY", "THURSDAY", "SATURDAY"]
}
}
{
"data": {
"updatePreferredTrainingDays": {
"id": "60f0cf0b2f8fb814a8a3d321",
"preferredTrainingDays": [2, 4, 6],
"currentPlanWeekNumber": 3,
"weeklySchedule": [
{
"id": "60f0cf0b2f8fb814a8a3d654",
"dayOfWeek": 2,
"scheduledDate": "2023-12-05T00:00:00.000Z",
"status": "SCHEDULED",
"workoutTemplateName": "Push Day"
},
{
"id": "60f0cf0b2f8fb814a8a3d655",
"dayOfWeek": 4,
"scheduledDate": "2023-12-07T00:00:00.000Z",
"status": "SCHEDULED",
"workoutTemplateName": "Pull Day"
},
{
"id": "60f0cf0b2f8fb814a8a3d656",
"dayOfWeek": 6,
"scheduledDate": "2023-12-09T00:00:00.000Z",
"status": "SCHEDULED",
"workoutTemplateName": "Leg Day"
}
]
}
}
}
Workout Execution Flow
Start Scheduled Workout
Begin a scheduled workout and create pre-filled workout history entry.
- GraphQL Mutation
- Variables
- Response
mutation StartScheduledWorkout($input: StartScheduledWorkoutInput!) {
startScheduledWorkout(input: $input) {
id
name
startTime
status
estimatedDurationMinutes
exercises {
id
exercise {
id
name
}
sets
repsMin
repsMax
restSeconds
targetWeight
workoutExerciseProgression {
currentProgressionWeek
baselineWeight
progressionState
}
}
}
}
{
"input": {
"scheduledWorkoutId": "60f0cf0b2f8fb814a8a3d654"
}
}
{
"data": {
"startScheduledWorkout": {
"id": "60f0cf0b2f8fb814a8a3d987",
"name": "Push Day - Week 3",
"startTime": "2023-12-05T09:30:00.000Z",
"status": "IN_PROGRESS",
"estimatedDurationMinutes": 75,
"exercises": [
{
"id": "60f0cf0b2f8fb814a8a3d988",
"exercise": {
"id": "60f0cf0b2f8fb814a8a3d111",
"name": "Bench Press"
},
"sets": 4,
"repsMin": 6,
"repsMax": 8,
"restSeconds": 180,
"targetWeight": 185.5,
"workoutExerciseProgression": {
"currentProgressionWeek": 3,
"baselineWeight": 175,
"progressionState": {
"lastSuccessfulWeight": 180,
"consecutiveSuccesses": 2,
"phase": "progression"
}
}
}
]
}
}
}
Complete Scheduled Workout
Mark a workout as completed and advance program progression.
- GraphQL Mutation
- Variables
- Response
mutation CompleteScheduledWorkout($input: CompleteScheduledWorkoutInput!) {
completeScheduledWorkout(input: $input) {
id
status
currentPlanWeekNumber
lastCompletedPwtOrderInWeek
lastActivityDate
# Check if week advanced
weeklySchedule(year: 2023, isoWeek: 50) {
id
dayOfWeek
planWeekNumber
status
isCatchUp
workoutTemplateName
}
}
}
{
"input": {
"scheduledWorkoutId": "60f0cf0b2f8fb814a8a3d654",
"workoutHistoryEntryId": "60f0cf0b2f8fb814a8a3d987"
}
}
{
"data": {
"completeScheduledWorkout": {
"id": "60f0cf0b2f8fb814a8a3d321",
"status": "ACTIVE",
"currentPlanWeekNumber": 4,
"lastCompletedPwtOrderInWeek": 3,
"lastActivityDate": "2023-12-05T11:15:00.000Z",
"weeklySchedule": [
{
"id": "60f0cf0b2f8fb814a8a3d771",
"dayOfWeek": 2,
"planWeekNumber": 4,
"status": "SCHEDULED",
"isCatchUp": false,
"workoutTemplateName": "Push Day"
},
{
"id": "60f0cf0b2f8fb814a8a3d772",
"dayOfWeek": 4,
"planWeekNumber": 4,
"status": "SCHEDULED",
"isCatchUp": false,
"workoutTemplateName": "Pull Day"
},
{
"id": "60f0cf0b2f8fb814a8a3d773",
"dayOfWeek": 6,
"planWeekNumber": 4,
"status": "SCHEDULED",
"isCatchUp": false,
"workoutTemplateName": "Leg Day"
}
]
}
}
}
Skip Scheduled Workout
Skip a workout and advance program progression.
mutation SkipScheduledWorkout($input: SkipScheduledWorkoutInput!) {
skipScheduledWorkout(input: $input) {
id
currentPlanWeekNumber
lastCompletedPwtOrderInWeek
lastActivityDate
}
}
Exercise Customization
Update Exercise Playbook
Customize progression playbook for a specific exercise within the program.
- GraphQL Mutation
- Variables
- Response
mutation UpdateExercisePlaybook($input: UpdateExercisePlaybookInput!) {
updateExercisePlaybook(input: $input) {
id
programSnapshot {
pwtSnapshots {
exerciseSnapshots {
masterExerciseId
progressionPlaybookId
progressionState
}
}
}
}
}
{
"input": {
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d321",
"exerciseId": "60f0cf0b2f8fb814a8a3d111",
"newPlaybookId": "60f0cf0b2f8fb814a8a3d999"
}
}
{
"data": {
"updateExercisePlaybook": {
"id": "60f0cf0b2f8fb814a8a3d321",
"programSnapshot": {
"pwtSnapshots": [
{
"exerciseSnapshots": [
{
"masterExerciseId": "60f0cf0b2f8fb814a8a3d111",
"progressionPlaybookId": "60f0cf0b2f8fb814a8a3d999",
"progressionState": {
"phase": "baseline",
"currentWeight": 175,
"sessionCount": 0
}
}
]
}
]
}
}
}
}
Flutter Integration
State Management Architecture
// Program instance state management
class ProgramInstanceProvider extends ChangeNotifier {
ProgramUserInstance? _activeProgram;
Map<String, List<UserProgramScheduledWeekWorkout>> _weeklySchedules = {};
ProgramUserInstance? get activeProgram => _activeProgram;
List<UserProgramScheduledWeekWorkout> getWeeklySchedule(int year, int isoWeek) {
return _weeklySchedules["$year-$isoWeek"] ?? [];
}
// ✅ CORRECT: Consume server intelligence
Future<void> enrollInProgram(String programPlanId, List<DayOfWeek> preferredDays) async {
final result = await _graphqlClient.mutate(EnrollInProgramMutation(
variables: EnrollInProgramArguments(
input: EnrollInProgramInput(
programPlanId: programPlanId,
preferredTrainingDays: preferredDays,
),
),
));
if (result.hasException) throw result.exception!;
_activeProgram = result.parsedData!.enrollInProgram;
notifyListeners();
}
// ✅ CORRECT: Let server handle scheduling logic
Future<List<UserProgramScheduledWeekWorkout>> loadWeeklySchedule(
int year,
int isoWeek
) async {
final result = await _graphqlClient.query(WeeklyScheduleQuery(
variables: WeeklyScheduleArguments(
puiId: _activeProgram!.id,
year: year,
isoWeek: isoWeek,
),
));
if (result.hasException) throw result.exception!;
final schedule = result.parsedData!.activeProgram!.weeklySchedule;
_weeklySchedules["$year-$isoWeek"] = schedule;
notifyListeners();
return schedule;
}
}
Program Enrollment Flow
class ProgramEnrollmentScreen extends StatefulWidget {
final String programPlanId;
const ProgramEnrollmentScreen({super.key, required this.programPlanId});
@override
State<ProgramEnrollmentScreen> createState() => _ProgramEnrollmentScreenState();
}
class _ProgramEnrollmentScreenState extends State<ProgramEnrollmentScreen> {
List<DayOfWeek> selectedTrainingDays = [];
String? selectedDefaultPlaybookId;
Map<String, String> exercisePlaybookOverrides = {};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Enroll in Program')),
body: Column(
children: [
// Training days selector
TrainingDaysSelector(
selectedDays: selectedTrainingDays,
onDaysChanged: (days) => setState(() => selectedTrainingDays = days),
),
// Playbook configuration
PlaybookConfigurationWidget(
selectedDefaultPlaybookId: selectedDefaultPlaybookId,
exerciseOverrides: exercisePlaybookOverrides,
onDefaultPlaybookChanged: (id) => setState(() => selectedDefaultPlaybookId = id),
onExerciseOverrideChanged: (exerciseId, playbookId) {
setState(() => exercisePlaybookOverrides[exerciseId] = playbookId);
},
),
// Enrollment button
ElevatedButton(
onPressed: _enrollInProgram,
child: const Text('Start Program'),
),
],
),
);
}
Future<void> _enrollInProgram() async {
try {
final provider = context.read<ProgramInstanceProvider>();
await provider.enrollInProgram(
widget.programPlanId,
selectedTrainingDays,
playbookConfiguration: PlaybookConfiguration(
defaultPlaybookId: selectedDefaultPlaybookId,
exerciseOverrides: exercisePlaybookOverrides.entries
.map((e) => ExercisePlaybookOverride(
exerciseId: e.key,
playbookId: e.value,
))
.toList(),
),
);
if (mounted) {
Navigator.pushReplacementNamed(context, '/program-dashboard');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Enrollment failed: $e')),
);
}
}
}
Weekly Schedule Display
class WeeklyScheduleWidget extends StatefulWidget {
final int year;
final int isoWeek;
const WeeklyScheduleWidget({
super.key,
required this.year,
required this.isoWeek,
});
@override
State<WeeklyScheduleWidget> createState() => _WeeklyScheduleWidgetState();
}
class _WeeklyScheduleWidgetState extends State<WeeklyScheduleWidget> {
@override
void initState() {
super.initState();
_loadSchedule();
}
Future<void> _loadSchedule() async {
final provider = context.read<ProgramInstanceProvider>();
await provider.loadWeeklySchedule(widget.year, widget.isoWeek);
}
@override
Widget build(BuildContext context) {
return Consumer<ProgramInstanceProvider>(
builder: (context, provider, child) {
final schedule = provider.getWeeklySchedule(widget.year, widget.isoWeek);
return Column(
children: [
Text(
'Week ${widget.isoWeek}, ${widget.year}',
style: Theme.of(context).textTheme.headlineSmall,
),
...schedule.map((workout) => WorkoutCard(
workout: workout,
onStart: () => _startWorkout(workout.id),
onComplete: (historyEntryId) => _completeWorkout(workout.id, historyEntryId),
onSkip: () => _skipWorkout(workout.id),
)),
],
);
},
);
}
Future<void> _startWorkout(String scheduledWorkoutId) async {
final provider = context.read<ProgramInstanceProvider>();
final historyEntry = await provider.startScheduledWorkout(scheduledWorkoutId);
if (mounted) {
Navigator.pushNamed(
context,
'/workout-session',
arguments: historyEntry.id,
);
}
}
Future<void> _completeWorkout(String scheduledWorkoutId, String historyEntryId) async {
final provider = context.read<ProgramInstanceProvider>();
await provider.completeScheduledWorkout(scheduledWorkoutId, historyEntryId);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Workout completed! Program advanced.')),
);
}
Future<void> _skipWorkout(String scheduledWorkoutId) async {
final provider = context.read<ProgramInstanceProvider>();
await provider.skipScheduledWorkout(scheduledWorkoutId);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Workout skipped. Program advanced.')),
);
}
}
Program State Management UI
class ProgramControlsWidget extends StatelessWidget {
final ProgramUserInstance program;
const ProgramControlsWidget({super.key, required this.program});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
program.programPlan.name,
style: Theme.of(context).textTheme.titleLarge,
),
Text('Status: ${program.status.name}'),
Text('Current Week: ${program.currentPlanWeekNumber}'),
const SizedBox(height: 16),
Row(
children: [
if (program.status == UserProgramInstanceStatus.active)
ElevatedButton(
onPressed: () => _pauseProgram(context),
child: const Text('Pause'),
),
if (program.status == UserProgramInstanceStatus.paused)
ElevatedButton(
onPressed: () => _resumeProgram(context),
child: const Text('Resume'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => _endProgram(context),
child: const Text('End Program'),
),
],
),
],
),
),
);
}
Future<void> _pauseProgram(BuildContext context) async {
final provider = context.read<ProgramInstanceProvider>();
await provider.pauseProgram(program.id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Program paused')),
);
}
Future<void> _resumeProgram(BuildContext context) async {
final provider = context.read<ProgramInstanceProvider>();
await provider.resumeProgram(program.id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Program resumed')),
);
}
Future<void> _endProgram(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('End Program'),
content: const Text('Are you sure you want to end this program? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('End Program'),
),
],
),
);
if (confirmed == true) {
final provider = context.read<ProgramInstanceProvider>();
await provider.endProgram(program.id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Program ended')),
);
}
}
}
Error Handling
Common Error Scenarios
# User already enrolled in another program
{
"errors": [
{
"message": "User is already enrolled in an active program",
"extensions": {
"code": "ALREADY_ENROLLED",
"activeProgram": {
"id": "60f0cf0b2f8fb814a8a3d321",
"status": "ACTIVE"
}
}
}
]
}
# Program not found
{
"errors": [
{
"message": "Program plan not found or not accessible",
"extensions": {
"code": "PROGRAM_NOT_FOUND"
}
}
]
}
# Invalid training days configuration
{
"errors": [
{
"message": "Preferred training days exceed program requirements",
"extensions": {
"code": "INVALID_TRAINING_DAYS",
"maxDaysPerWeek": 3,
"providedDays": 5
}
}
]
}
# Workout already completed
{
"errors": [
{
"message": "Cannot complete workout that is already marked as completed",
"extensions": {
"code": "WORKOUT_ALREADY_COMPLETED",
"workoutStatus": "COMPLETED"
}
}
]
}
Flutter Error Handling
class ProgramErrorHandler {
static void handleProgramError(BuildContext context, dynamic error) {
String message = 'An unexpected error occurred';
if (error is GraphQLError) {
final code = error.extensions?['code'];
switch (code) {
case 'ALREADY_ENROLLED':
message = 'You are already enrolled in another program. Please complete or end your current program first.';
break;
case 'PROGRAM_NOT_FOUND':
message = 'This program is no longer available.';
break;
case 'INVALID_TRAINING_DAYS':
final maxDays = error.extensions?['maxDaysPerWeek'];
message = 'You can select a maximum of $maxDays training days for this program.';
break;
case 'WORKOUT_ALREADY_COMPLETED':
message = 'This workout has already been completed.';
break;
default:
message = error.message;
}
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
Testing Examples
Integration Tests
void main() {
group('Program User Instance Integration Tests', () {
testWidgets('complete program enrollment flow', (tester) async {
// Arrange: Mock GraphQL responses
when(mockClient.mutate<EnrollInProgram>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'enrollInProgram': {
'id': 'test_pui_id',
'status': 'ACTIVE',
'startDate': DateTime.now().toIso8601String(),
'currentPlanWeekNumber': 1,
'preferredTrainingDays': [1, 3, 5],
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Act: Navigate through enrollment flow
await tester.pumpWidget(testApp);
await tester.tap(find.text('Enroll in Program'));
await tester.pumpAndSettle();
// Select training days
await tester.tap(find.text('Monday'));
await tester.tap(find.text('Wednesday'));
await tester.tap(find.text('Friday'));
await tester.tap(find.text('Start Program'));
await tester.pumpAndSettle();
// Assert: Program enrollment successful
expect(find.text('Program Dashboard'), findsOneWidget);
expect(find.text('Week 1'), findsOneWidget);
});
testWidgets('handle workout completion flow', (tester) async {
// Arrange: Mock active program and schedule
when(mockClient.query<ActiveProgram>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'activeProgram': {
'id': 'test_pui_id',
'weeklySchedule': [
{
'id': 'scheduled_workout_id',
'dayOfWeek': 1,
'status': 'SCHEDULED',
'workoutTemplateName': 'Push Day',
}
]
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Mock workout start
when(mockClient.mutate<StartScheduledWorkout>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'startScheduledWorkout': {
'id': 'workout_history_id',
'name': 'Push Day - Week 1',
'status': 'IN_PROGRESS',
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Act: Start and complete workout
await tester.pumpWidget(testApp);
await tester.tap(find.text('Start Workout'));
await tester.pumpAndSettle();
// Navigate to completion
await tester.tap(find.text('Complete Workout'));
await tester.pumpAndSettle();
// Assert: Workout completed successfully
expect(find.text('Workout Completed!'), findsOneWidget);
});
});
}
Unit Tests
void main() {
group('ProgramInstanceProvider', () {
late ProgramInstanceProvider provider;
late MockGraphQLClient mockClient;
setUp(() {
mockClient = MockGraphQLClient();
provider = ProgramInstanceProvider(mockClient);
});
test('enrollInProgram creates active program instance', () async {
// Arrange
when(mockClient.mutate<EnrollInProgram>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'enrollInProgram': {
'id': 'test_pui_id',
'status': 'ACTIVE',
'currentPlanWeekNumber': 1,
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Act
await provider.enrollInProgram('program_id', [DayOfWeek.monday, DayOfWeek.wednesday, DayOfWeek.friday]);
// Assert
expect(provider.activeProgram, isNotNull);
expect(provider.activeProgram!.status, equals(UserProgramInstanceStatus.active));
expect(provider.activeProgram!.currentPlanWeekNumber, equals(1));
});
test('loadWeeklySchedule caches schedule data', () async {
// Arrange
when(mockClient.query<WeeklySchedule>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'activeProgram': {
'weeklySchedule': [
{
'id': 'workout_1',
'dayOfWeek': 1,
'status': 'SCHEDULED',
},
{
'id': 'workout_2',
'dayOfWeek': 3,
'status': 'SCHEDULED',
}
]
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
provider.activeProgram = ProgramUserInstance(id: 'test_pui_id');
// Act
final schedule = await provider.loadWeeklySchedule(2023, 49);
// Assert
expect(schedule.length, equals(2));
expect(provider.getWeeklySchedule(2023, 49).length, equals(2));
});
});
}
Business Logic Summary
The Program User Instance API enables comprehensive user program management with:
⚠️ IMPLEMENTATION BOUNDARY
All scheduling logic, progression rules, and workout validation live server-side only. Flutter must NEVER implement program business logic locally.
Server-Side Intelligence:
- Automatic Scheduling: Generates weekly workout schedules based on program type and user preferences
- Progression Logic: Advances users through program weeks and workout sequences
- Catch-up Management: Automatically handles missed workout recovery
- Exercise Customization: Manages exercise-specific playbook assignments
- State Validation: Enforces program status transitions and workflow rules
Client Responsibilities:
- Display program enrollment forms and user preferences
- Show weekly schedules and workout calendars
- Handle workout start/completion user interactions
- Present program progress and status updates
- Cache program data for offline viewing