Program Plans API
The Program Plans API provides a comprehensive fitness program catalog system that manages structured workout programs, enables program discovery through advanced filtering, provides AI-powered recommendations, and handles program creation and management. This system serves as the foundation for organized training progression in OpenLift.
⚠️ CRITICAL: THIN CLIENT IMPLEMENTATION ONLY
What Flutter SHOULD do:
- ✅ Consume GraphQL endpoints for program data
- ✅ Display program catalogs and details
- ✅ Handle filtering UI and search interfaces
- ✅ Cache program data for offline browsing
What Flutter MUST NOT do:
- ❌ Implement program recommendation algorithms
- ❌ Calculate program progression or weekly modifications
- ❌ Duplicate program filtering business logic
- ❌ Make decisions about program suitability
🏗️ Program Plans Architecture
Core Program Structure
Program Plans use a hierarchical structure designed for progressive training:
interface ProgramPlan {
// Basic Information
id: string;
name: string;
description: string;
author?: string;
version?: string;
// Program Configuration
trainingFocus: TrainingFocus;
level: TrainingLevel; // BEGINNER | INTERMEDIATE | ADVANCED
goal: TrainingGoal; // MUSCLE_GAIN | STRENGTH_GAIN | WEIGHT_LOSS | ENDURANCE
durationWeeks: number; // Program length
daysPerWeek: number; // Training frequency
// Program Structure
workoutTemplateLinks: TemplateLink[]; // Ordered workout templates
weeklyModifiers: WeeklyModifier[]; // Progressive modifications
loopWeekly: boolean; // Whether program repeats
// Requirements & Categorization
equipment: string[]; // Required equipment
tags: string[]; // Program tags/keywords
workoutSplit?: WorkoutSplit; // Categorization split
// Publishing & Access
visibility: Visibility; // PUBLIC | PRIVATE | SHARED
programType: ProgramType; // STOCK | CUSTOM | COMMUNITY
isPublished: boolean; // Available in catalog
stock: boolean; // OpenLift official program
// Media & Presentation
coverImageFileKey?: string;
coverImageUrl?: string; // Signed URL
}
Program Structure Components
- Template Links - Ordered list of workout templates that make up the program
- Weekly Modifiers - Progressive changes to intensity/volume by week
- Training Focus - Primary muscle group or training emphasis
- Equipment Requirements - List of required gym equipment
- Workout Split - Training split categorization (Push/Pull/Legs, Upper/Lower, etc.)
🔍 GraphQL Schema
Core Types
- ProgramPlan Type
- Supporting Types
- Enums & Constants
type ProgramPlan {
id: ID!
name: String!
description: String!
author: String
version: String
# Program Configuration
trainingFocus: ProgramTrainingFocus!
level: TrainingLevel!
goal: TrainingGoal!
durationWeeks: Int!
daysPerWeek: Int!
loopWeekly: Boolean!
# Structure & Content
workoutTemplateLinks: [ProgramPlanTemplateLink!]!
weeklyModifiers: [PlanWeeklyModifier!]!
# Requirements
equipment: [String!]!
tags: [String!]!
workoutSplit: WorkoutSplit
# Publishing
visibility: Visibility!
programType: ProgramType!
isPublished: Boolean!
stock: Boolean!
deleted: Boolean!
# Media
coverImageFileKey: String
coverImageUrl: String
# Split Analysis
splitConfidence: Float
userConfirmedSplit: Boolean!
# Relations
createdBy: User!
createdAt: DateTime!
}
type ProgramPlanTemplateLink {
programWorkoutTemplateId: String!
order: Int!
}
type PlanWeeklyModifier {
weekNumber: Int!
name: String!
description: String
rpeMultiplier: Float!
repsMultiplier: Float!
weightMultiplier: Float!
}
type ProgramTrainingFocus {
id: ID!
name: String!
description: String
primaryMuscleGroups: [MuscleGroup!]!
}
type WorkoutSplit {
id: ID!
name: String!
description: String
daysPerWeek: Int!
categories: [String!]!
}
enum TrainingLevel {
BEGINNER
INTERMEDIATE
ADVANCED
}
enum TrainingGoal {
MUSCLE_GAIN
STRENGTH_GAIN
WEIGHT_LOSS
ENDURANCE
GENERAL_FITNESS
}
enum Visibility {
PUBLIC
PRIVATE
SHARED
}
enum ProgramType {
STOCK # OpenLift official programs
CUSTOM # User-created programs
COMMUNITY # Community-shared programs
}
# Common Equipment Types
scalar Equipment = [
"BARBELL",
"DUMBBELLS",
"KETTLEBELLS",
"RESISTANCE_BANDS",
"PULL_UP_BAR",
"BENCH",
"SQUAT_RACK",
"CABLE_MACHINE",
"BODYWEIGHT_ONLY"
]
🔧 GraphQL Operations
Queries
- Get Single Program
- Browse Program Catalog
- AI Program Recommendations
query ProgramPlan($id: ID!) {
programPlan(id: $id) {
id
name
description
author
version
trainingFocus {
id
name
description
primaryMuscleGroups {
id
name
}
}
level
goal
durationWeeks
daysPerWeek
loopWeekly
workoutTemplateLinks {
programWorkoutTemplateId
order
}
weeklyModifiers {
weekNumber
name
description
rpeMultiplier
repsMultiplier
weightMultiplier
}
equipment
tags
workoutSplit {
id
name
description
daysPerWeek
}
visibility
programType
isPublished
stock
coverImageFileKey
coverImageUrl
splitConfidence
userConfirmedSplit
createdBy {
id
username
fullName
}
createdAt
}
}
Variables:
{
"id": "60d5ecb54f3d4d2e8c8e4b5a"
}
Response Example:
{
"data": {
"programPlan": {
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"name": "Beginner Strength Builder",
"description": "A 12-week progressive strength program designed for beginners focusing on compound movements and foundational strength development.",
"author": "OpenLift Team",
"version": "v2.1",
"trainingFocus": {
"id": "full_body_strength",
"name": "Full Body Strength",
"description": "Comprehensive strength training targeting all major muscle groups",
"primaryMuscleGroups": [
{"id": "chest", "name": "Chest"},
{"id": "back", "name": "Back"},
{"id": "legs", "name": "Legs"}
]
},
"level": "BEGINNER",
"goal": "STRENGTH_GAIN",
"durationWeeks": 12,
"daysPerWeek": 3,
"loopWeekly": false,
"workoutTemplateLinks": [
{
"programWorkoutTemplateId": "template_1_full_body_a",
"order": 1
},
{
"programWorkoutTemplateId": "template_2_full_body_b",
"order": 2
}
],
"weeklyModifiers": [
{
"weekNumber": 1,
"name": "Foundation Week",
"description": "Focus on form and technique",
"rpeMultiplier": 0.6,
"repsMultiplier": 1.0,
"weightMultiplier": 0.7
},
{
"weekNumber": 4,
"name": "Progression Week",
"description": "Increase intensity",
"rpeMultiplier": 0.8,
"repsMultiplier": 1.0,
"weightMultiplier": 0.85
}
],
"equipment": ["BARBELL", "DUMBBELLS", "BENCH", "SQUAT_RACK"],
"tags": ["beginner", "strength", "compound", "progressive"],
"workoutSplit": {
"id": "full_body",
"name": "Full Body",
"description": "Complete body workout targeting all muscle groups",
"daysPerWeek": 3
},
"visibility": "PUBLIC",
"programType": "STOCK",
"isPublished": true,
"stock": true,
"coverImageFileKey": "program_covers/beginner_strength.jpg",
"coverImageUrl": "https://storage.example.com/program_covers/beginner_strength.jpg?expires=1640999999",
"splitConfidence": 0.95,
"userConfirmedSplit": true,
"createdBy": {
"id": "admin_user",
"username": "openlift_admin",
"fullName": "OpenLift Team"
},
"createdAt": "2023-01-01T10:00:00.000Z"
}
}
}
Authorization: Public programs accessible to all; private programs require ownership Use Cases: Program detail views, program preview, enrollment preparation
query ProgramPlans(
$filter: ProgramPlanFilterInput
$limit: Int
$offset: Int
) {
programPlans(
filter: $filter
limit: $limit
offset: $offset
) {
id
name
description
author
trainingFocus {
name
}
level
goal
durationWeeks
daysPerWeek
equipment
tags
workoutSplit {
name
daysPerWeek
}
programType
stock
coverImageUrl
createdBy {
username
fullName
}
createdAt
}
}
Filter Input Type:
input ProgramPlanFilterInput {
# Workout split filtering
workoutSplitId: ID
workoutSplitIds: [ID!]
# Program characteristics
level: TrainingLevel
goal: TrainingGoal
# Training frequency
daysPerWeek: Int # Exact match
daysPerWeekMin: Int # Minimum days
daysPerWeekMax: Int # Maximum days
# Duration filtering
durationWeeks: Int
durationWeeksMin: Int
durationWeeksMax: Int
# Equipment filtering
equipment: [String!] # Must have ALL listed equipment
equipmentAny: [String!] # Must have ANY of listed equipment
# Content search
search: String # Search in name/description
tags: [String!] # Must have ALL tags
tagsAny: [String!] # Must have ANY tag
# Program types
programTypes: [ProgramType!]
includeStock: Boolean # Include OpenLift official programs
includeCustom: Boolean # Include user-created programs
includeCommunity: Boolean # Include community programs
}
Variables Example:
{
"filter": {
"level": "BEGINNER",
"goal": "MUSCLE_GAIN",
"daysPerWeekMin": 3,
"daysPerWeekMax": 5,
"equipment": ["DUMBBELLS", "BENCH"],
"includeStock": true,
"search": "strength"
},
"limit": 20,
"offset": 0
}
Response:
{
"data": {
"programPlans": [
{
"id": "program_1",
"name": "Beginner Dumbbell Strength",
"description": "Perfect for home gym setups with basic equipment",
"author": "OpenLift Team",
"trainingFocus": {
"name": "Full Body Strength"
},
"level": "BEGINNER",
"goal": "MUSCLE_GAIN",
"durationWeeks": 8,
"daysPerWeek": 4,
"equipment": ["DUMBBELLS", "BENCH"],
"tags": ["beginner", "dumbbell", "home-gym"],
"workoutSplit": {
"name": "Upper/Lower",
"daysPerWeek": 4
},
"programType": "STOCK",
"stock": true,
"coverImageUrl": "https://storage.example.com/covers/dumbbell_strength.jpg",
"createdBy": {
"username": "openlift_admin",
"fullName": "OpenLift Team"
},
"createdAt": "2023-01-01T10:00:00.000Z"
}
]
}
}
Use Cases:
- Program browsing and discovery
- Filtered searches by user preferences
- Equipment-based program recommendations
query RecommendedPrograms($limit: Int) {
recommendedPrograms(limit: $limit) {
id
name
description
author
level
goal
durationWeeks
daysPerWeek
equipment
workoutSplit {
name
description
}
coverImageUrl
# Recommendation metadata
recommendationScore
recommendationReasons
matchingCriteria {
trainingGoal
trainingLevel
availableEquipment
trainingFrequency
}
}
}
Variables:
{
"limit": 10
}
Response:
{
"data": {
"recommendedPrograms": [
{
"id": "recommended_program_1",
"name": "Beginner Muscle Builder",
"description": "Designed specifically for muscle gain with available equipment",
"author": "OpenLift Team",
"level": "BEGINNER",
"goal": "MUSCLE_GAIN",
"durationWeeks": 12,
"daysPerWeek": 4,
"equipment": ["DUMBBELLS", "RESISTANCE_BANDS"],
"workoutSplit": {
"name": "Push/Pull/Legs",
"description": "Balanced approach targeting all muscle groups"
},
"coverImageUrl": "https://storage.example.com/covers/muscle_builder.jpg",
"recommendationScore": 0.92,
"recommendationReasons": [
"Matches your muscle gain goal",
"Uses your available equipment",
"Suitable for your training level",
"Fits your 4-day training frequency"
],
"matchingCriteria": {
"trainingGoal": "MUSCLE_GAIN",
"trainingLevel": "BEGINNER",
"availableEquipment": ["DUMBBELLS", "RESISTANCE_BANDS"],
"trainingFrequency": 4
}
}
]
}
}
Business Logic:
- Based on user onboarding data (goals, level, equipment, frequency)
- Machine learning scoring algorithm (server-side)
- Considers user's workout history and preferences
- Filters out programs with unavailable equipment
Authorization: Authenticated users only (requires onboarding data)
Mutations
- Create Program Plan
- Update Program Plan
- Delete Program Plan
mutation CreateProgramPlan($input: CreateProgramPlanInput!) {
createProgramPlan(input: $input) {
id
name
description
trainingFocus {
id
name
}
level
goal
durationWeeks
daysPerWeek
workoutTemplateLinks {
programWorkoutTemplateId
order
}
weeklyModifiers {
weekNumber
name
rpeMultiplier
repsMultiplier
weightMultiplier
}
equipment
tags
visibility
programType
isPublished
createdAt
}
}
Input Type:
input CreateProgramPlanInput {
# Required fields
name: String!
description: String!
trainingFocusId: ID!
level: TrainingLevel!
goal: TrainingGoal!
durationWeeks: Int!
daysPerWeek: Int!
# Optional metadata
version: String
author: String
coverImageFileKey: String
# Configuration
visibility: Visibility # Default: PRIVATE
programType: ProgramType # Default: CUSTOM
loopWeekly: Boolean # Default: false
isPublished: Boolean # Default: false
stock: Boolean # Default: false
# Requirements & categorization
equipment: [String!]
tags: [String!]
workoutSplitId: ID
userConfirmedSplit: Boolean
# Program structure
workoutTemplateLinks: [ProgramPlanTemplateLinkInput!]
weeklyModifiers: [ProgramPlanWeeklyModifierInput!]
}
input ProgramPlanTemplateLinkInput {
programWorkoutTemplateId: ID!
order: Int!
}
input ProgramPlanWeeklyModifierInput {
weekNumber: Int!
defaultModifierId: ID # Reference to default modifier
name: String # Custom modifier name
description: String
rpeMultiplier: Float
repsMultiplier: Float
weightMultiplier: Float
}
Variables Example:
{
"input": {
"name": "My Custom Upper/Lower Split",
"description": "A personalized 4-day upper/lower split focusing on strength and hypertrophy",
"trainingFocusId": "strength_hypertrophy",
"level": "INTERMEDIATE",
"goal": "MUSCLE_GAIN",
"durationWeeks": 8,
"daysPerWeek": 4,
"author": "John Doe",
"visibility": "PRIVATE",
"programType": "CUSTOM",
"equipment": ["BARBELL", "DUMBBELLS", "BENCH", "CABLE_MACHINE"],
"tags": ["upper-lower", "hypertrophy", "strength"],
"workoutSplitId": "upper_lower_4day",
"workoutTemplateLinks": [
{
"programWorkoutTemplateId": "template_upper_1",
"order": 1
},
{
"programWorkoutTemplateId": "template_lower_1",
"order": 2
},
{
"programWorkoutTemplateId": "template_upper_2",
"order": 3
},
{
"programWorkoutTemplateId": "template_lower_2",
"order": 4
}
],
"weeklyModifiers": [
{
"weekNumber": 1,
"name": "Base Week",
"description": "Establish baseline",
"rpeMultiplier": 0.7,
"repsMultiplier": 1.0,
"weightMultiplier": 0.8
},
{
"weekNumber": 4,
"name": "Intensity Week",
"description": "Peak intensity",
"rpeMultiplier": 0.9,
"repsMultiplier": 1.0,
"weightMultiplier": 0.95
}
]
}
}
Business Rules:
- Only authenticated users can create programs
trainingFocusIdmust exist in the systemworkoutTemplateLinksmust reference valid templates- Weekly modifiers must have unique week numbers
- Equipment list validated against supported equipment types
Error Handling:
NOT_FOUND- Invalid training focus or template IDBAD_USER_INPUT- Invalid equipment type or duplicate week numbersUNAUTHORIZED- Authentication required
mutation UpdateProgramPlan(
$planId: ID!
$input: UpdateProgramPlanInput!
) {
updateProgramPlan(planId: $planId, input: $input) {
id
name
description
level
goal
durationWeeks
daysPerWeek
equipment
tags
isPublished
visibility
updatedAt
}
}
Input Type:
input UpdateProgramPlanInput {
# All fields optional for partial updates
name: String
description: String
trainingFocusId: ID
level: TrainingLevel
goal: TrainingGoal
durationWeeks: Int
daysPerWeek: Int
version: String
author: String
coverImageFileKey: String
visibility: Visibility
programType: ProgramType
loopWeekly: Boolean
equipment: [String!]
tags: [String!]
isPublished: Boolean
stock: Boolean
workoutSplitId: ID
userConfirmedSplit: Boolean
workoutTemplateLinks: [ProgramPlanTemplateLinkInput!]
weeklyModifiers: [ProgramPlanWeeklyModifierInput!]
}
Variables Example:
{
"planId": "60d5ecb54f3d4d2e8c8e4b5a",
"input": {
"name": "Updated Program Name",
"description": "Updated description with new training approach",
"durationWeeks": 10,
"equipment": ["BARBELL", "DUMBBELLS", "BENCH"],
"isPublished": true,
"visibility": "PUBLIC"
}
}
Authorization: Only program owner can update
Partial Updates: All fields optional - only provided fields are updated
Publishing Rules: Setting isPublished: true makes program visible in catalog
mutation DeleteProgramPlan($planId: ID!) {
deleteProgramPlan(planId: $planId) {
id
name
deleted
isPublished
updatedAt
}
}
Variables:
{
"planId": "60d5ecb54f3d4d2e8c8e4b5a"
}
Response:
{
"data": {
"deleteProgramPlan": {
"id": "60d5ecb54f3d4d2e8c8e4b5a",
"name": "My Custom Program",
"deleted": true,
"isPublished": false,
"updatedAt": "2023-12-01T10:30:00.000Z"
}
}
}
Business Logic:
- Soft delete - sets
deleted: true - Automatically unpublishes program (
isPublished: false) - Program becomes inaccessible to other users
- Owner can still see deleted programs in their list
- Data preserved for potential recovery
Authorization: Only program owner can delete
📱 Flutter Implementation
Complete Program Plans Integration
- Model Classes
- Program Plans Service
- Program Catalog UI
import 'package:json_annotation/json_annotation.dart';
part 'program_plan_models.g.dart';
@JsonSerializable()
class ProgramPlan {
final String id;
final String name;
final String description;
final String? author;
final String? version;
final ProgramTrainingFocus trainingFocus;
final TrainingLevel level;
final TrainingGoal goal;
final int durationWeeks;
final int daysPerWeek;
final bool loopWeekly;
final List<ProgramTemplateLink> workoutTemplateLinks;
final List<WeeklyModifier> weeklyModifiers;
final List<String> equipment;
final List<String> tags;
final WorkoutSplit? workoutSplit;
final Visibility visibility;
final ProgramType programType;
final bool isPublished;
final bool stock;
final bool deleted;
final String? coverImageFileKey;
final String? coverImageUrl;
final double? splitConfidence;
final bool userConfirmedSplit;
final User createdBy;
final DateTime createdAt;
ProgramPlan({
required this.id,
required this.name,
required this.description,
this.author,
this.version,
required this.trainingFocus,
required this.level,
required this.goal,
required this.durationWeeks,
required this.daysPerWeek,
required this.loopWeekly,
required this.workoutTemplateLinks,
required this.weeklyModifiers,
required this.equipment,
required this.tags,
this.workoutSplit,
required this.visibility,
required this.programType,
required this.isPublished,
required this.stock,
required this.deleted,
this.coverImageFileKey,
this.coverImageUrl,
this.splitConfidence,
required this.userConfirmedSplit,
required this.createdBy,
required this.createdAt,
});
factory ProgramPlan.fromJson(Map<String, dynamic> json) =>
_$ProgramPlanFromJson(json);
Map<String, dynamic> toJson() => _$ProgramPlanToJson(this);
// Helper methods
bool get isOfficial => stock;
bool get isCustom => programType == ProgramType.custom;
bool get isCommunity => programType == ProgramType.community;
String get durationDisplay => '$durationWeeks weeks';
String get frequencyDisplay => '$daysPerWeek days/week';
String get equipmentSummary => equipment.join(', ');
bool get hasProgressiveModifications => weeklyModifiers.isNotEmpty;
}
@JsonSerializable()
class ProgramTemplateLink {
final String programWorkoutTemplateId;
final int order;
ProgramTemplateLink({
required this.programWorkoutTemplateId,
required this.order,
});
factory ProgramTemplateLink.fromJson(Map<String, dynamic> json) =>
_$ProgramTemplateLinkFromJson(json);
Map<String, dynamic> toJson() => _$ProgramTemplateLinkToJson(this);
}
@JsonSerializable()
class WeeklyModifier {
final int weekNumber;
final String name;
final String? description;
final double rpeMultiplier;
final double repsMultiplier;
final double weightMultiplier;
WeeklyModifier({
required this.weekNumber,
required this.name,
this.description,
required this.rpeMultiplier,
required this.repsMultiplier,
required this.weightMultiplier,
});
factory WeeklyModifier.fromJson(Map<String, dynamic> json) =>
_$WeeklyModifierFromJson(json);
Map<String, dynamic> toJson() => _$WeeklyModifierToJson(this);
// Helper methods
String get intensityLevel {
if (rpeMultiplier >= 0.9) return 'High';
if (rpeMultiplier >= 0.7) return 'Moderate';
return 'Low';
}
bool get isDeload => rpeMultiplier < 0.7 || weightMultiplier < 0.8;
bool get isPeakWeek => rpeMultiplier >= 0.9;
}
@JsonSerializable()
class ProgramTrainingFocus {
final String id;
final String name;
final String? description;
final List<MuscleGroup> primaryMuscleGroups;
ProgramTrainingFocus({
required this.id,
required this.name,
this.description,
required this.primaryMuscleGroups,
});
factory ProgramTrainingFocus.fromJson(Map<String, dynamic> json) =>
_$ProgramTrainingFocusFromJson(json);
Map<String, dynamic> toJson() => _$ProgramTrainingFocusToJson(this);
}
@JsonSerializable()
class WorkoutSplit {
final String id;
final String name;
final String? description;
final int daysPerWeek;
WorkoutSplit({
required this.id,
required this.name,
this.description,
required this.daysPerWeek,
});
factory WorkoutSplit.fromJson(Map<String, dynamic> json) =>
_$WorkoutSplitFromJson(json);
Map<String, dynamic> toJson() => _$WorkoutSplitToJson(this);
}
// Filter model for searching programs
@JsonSerializable()
class ProgramPlanFilter {
final String? workoutSplitId;
final List<String>? workoutSplitIds;
final TrainingLevel? level;
final TrainingGoal? goal;
final int? daysPerWeek;
final int? daysPerWeekMin;
final int? daysPerWeekMax;
final int? durationWeeks;
final int? durationWeeksMin;
final int? durationWeeksMax;
final List<String>? equipment;
final List<String>? equipmentAny;
final String? search;
final List<String>? tags;
final List<String>? tagsAny;
final List<ProgramType>? programTypes;
final bool? includeStock;
final bool? includeCustom;
final bool? includeCommunity;
ProgramPlanFilter({
this.workoutSplitId,
this.workoutSplitIds,
this.level,
this.goal,
this.daysPerWeek,
this.daysPerWeekMin,
this.daysPerWeekMax,
this.durationWeeks,
this.durationWeeksMin,
this.durationWeeksMax,
this.equipment,
this.equipmentAny,
this.search,
this.tags,
this.tagsAny,
this.programTypes,
this.includeStock,
this.includeCustom,
this.includeCommunity,
});
factory ProgramPlanFilter.fromJson(Map<String, dynamic> json) =>
_$ProgramPlanFilterFromJson(json);
Map<String, dynamic> toJson() => _$ProgramPlanFilterToJson(this);
}
// Enums
enum TrainingLevel { beginner, intermediate, advanced }
enum TrainingGoal { muscleGain, strengthGain, weightLoss, endurance, generalFitness }
enum Visibility { public, private, shared }
enum ProgramType { stock, custom, community }
import 'package:graphql_flutter/graphql_flutter.dart';
import 'program_plan_models.dart';
class ProgramPlansService {
final GraphQLClient _client;
ProgramPlansService(this._client);
// Get single program plan
Future<ProgramPlan?> getProgramPlan(String id) async {
const String query = '''
query ProgramPlan(\$id: ID!) {
programPlan(id: \$id) {
id
name
description
author
version
trainingFocus {
id
name
description
primaryMuscleGroups {
id
name
}
}
level
goal
durationWeeks
daysPerWeek
loopWeekly
workoutTemplateLinks {
programWorkoutTemplateId
order
}
weeklyModifiers {
weekNumber
name
description
rpeMultiplier
repsMultiplier
weightMultiplier
}
equipment
tags
workoutSplit {
id
name
description
daysPerWeek
}
visibility
programType
isPublished
stock
deleted
coverImageFileKey
coverImageUrl
splitConfidence
userConfirmedSplit
createdBy {
id
username
fullName
}
createdAt
}
}
''';
final QueryOptions options = QueryOptions(
document: gql(query),
variables: {'id': id},
fetchPolicy: FetchPolicy.cacheAndNetwork,
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw ProgramPlansServiceException.fromGraphQLError(result.exception!);
}
final programData = result.data?['programPlan'];
return programData != null ? ProgramPlan.fromJson(programData) : null;
}
// Get program catalog with filtering
Future<List<ProgramPlan>> getProgramPlans({
ProgramPlanFilter? filter,
int limit = 20,
int offset = 0,
}) async {
const String query = '''
query ProgramPlans(
\$filter: ProgramPlanFilterInput
\$limit: Int
\$offset: Int
) {
programPlans(
filter: \$filter
limit: \$limit
offset: \$offset
) {
id
name
description
author
trainingFocus {
name
}
level
goal
durationWeeks
daysPerWeek
equipment
tags
workoutSplit {
name
daysPerWeek
}
programType
stock
coverImageUrl
createdBy {
username
fullName
}
createdAt
}
}
''';
final QueryOptions options = QueryOptions(
document: gql(query),
variables: {
'filter': filter?.toJson(),
'limit': limit,
'offset': offset,
}..removeWhere((key, value) => value == null),
fetchPolicy: FetchPolicy.cacheAndNetwork,
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw ProgramPlansServiceException.fromGraphQLError(result.exception!);
}
final programsData = result.data?['programPlans'] as List<dynamic>? ?? [];
return programsData.map((p) => ProgramPlan.fromJson(p)).toList();
}
// Get AI-powered program recommendations
Future<List<RecommendedProgram>> getRecommendedPrograms({
int limit = 10,
}) async {
const String query = '''
query RecommendedPrograms(\$limit: Int) {
recommendedPrograms(limit: \$limit) {
id
name
description
author
level
goal
durationWeeks
daysPerWeek
equipment
workoutSplit {
name
description
}
coverImageUrl
recommendationScore
recommendationReasons
matchingCriteria {
trainingGoal
trainingLevel
availableEquipment
trainingFrequency
}
}
}
''';
final QueryOptions options = QueryOptions(
document: gql(query),
variables: {'limit': limit},
fetchPolicy: FetchPolicy.cacheFirst, // Recommendations can be cached briefly
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw ProgramPlansServiceException.fromGraphQLError(result.exception!);
}
final recommendationsData = result.data?['recommendedPrograms'] as List<dynamic>? ?? [];
return recommendationsData.map((r) => RecommendedProgram.fromJson(r)).toList();
}
// Create new program plan
Future<ProgramPlan> createProgramPlan(CreateProgramPlanInput input) async {
const String mutation = '''
mutation CreateProgramPlan(\$input: CreateProgramPlanInput!) {
createProgramPlan(input: \$input) {
id
name
description
trainingFocus {
id
name
}
level
goal
durationWeeks
daysPerWeek
workoutTemplateLinks {
programWorkoutTemplateId
order
}
weeklyModifiers {
weekNumber
name
rpeMultiplier
repsMultiplier
weightMultiplier
}
equipment
tags
visibility
programType
isPublished
createdAt
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'input': input.toJson()},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw ProgramPlansServiceException.fromGraphQLError(result.exception!);
}
return ProgramPlan.fromJson(result.data!['createProgramPlan']);
}
// Update existing program plan
Future<ProgramPlan> updateProgramPlan(
String planId,
UpdateProgramPlanInput input,
) async {
const String mutation = '''
mutation UpdateProgramPlan(\$planId: ID!, \$input: UpdateProgramPlanInput!) {
updateProgramPlan(planId: \$planId, input: \$input) {
id
name
description
level
goal
durationWeeks
daysPerWeek
equipment
tags
isPublished
visibility
updatedAt
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {
'planId': planId,
'input': input.toJson(),
},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw ProgramPlansServiceException.fromGraphQLError(result.exception!);
}
return ProgramPlan.fromJson(result.data!['updateProgramPlan']);
}
// Delete program plan
Future<ProgramPlan> deleteProgramPlan(String planId) async {
const String mutation = '''
mutation DeleteProgramPlan(\$planId: ID!) {
deleteProgramPlan(planId: \$planId) {
id
name
deleted
isPublished
updatedAt
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'planId': planId},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw ProgramPlansServiceException.fromGraphQLError(result.exception!);
}
return ProgramPlan.fromJson(result.data!['deleteProgramPlan']);
}
}
// Input data classes
class CreateProgramPlanInput {
final String name;
final String description;
final String trainingFocusId;
final TrainingLevel level;
final TrainingGoal goal;
final int durationWeeks;
final int daysPerWeek;
final String? version;
final String? author;
final String? coverImageFileKey;
final Visibility? visibility;
final ProgramType? programType;
final bool? loopWeekly;
final List<String>? equipment;
final List<String>? tags;
final bool? isPublished;
final bool? stock;
final String? workoutSplitId;
final bool? userConfirmedSplit;
final List<ProgramTemplateLinkInput>? workoutTemplateLinks;
final List<WeeklyModifierInput>? weeklyModifiers;
CreateProgramPlanInput({
required this.name,
required this.description,
required this.trainingFocusId,
required this.level,
required this.goal,
required this.durationWeeks,
required this.daysPerWeek,
this.version,
this.author,
this.coverImageFileKey,
this.visibility,
this.programType,
this.loopWeekly,
this.equipment,
this.tags,
this.isPublished,
this.stock,
this.workoutSplitId,
this.userConfirmedSplit,
this.workoutTemplateLinks,
this.weeklyModifiers,
});
Map<String, dynamic> toJson() => {
'name': name,
'description': description,
'trainingFocusId': trainingFocusId,
'level': level.name.toUpperCase(),
'goal': goal.name.toUpperCase(),
'durationWeeks': durationWeeks,
'daysPerWeek': daysPerWeek,
if (version != null) 'version': version,
if (author != null) 'author': author,
if (coverImageFileKey != null) 'coverImageFileKey': coverImageFileKey,
if (visibility != null) 'visibility': visibility!.name.toUpperCase(),
if (programType != null) 'programType': programType!.name.toUpperCase(),
if (loopWeekly != null) 'loopWeekly': loopWeekly,
if (equipment != null) 'equipment': equipment,
if (tags != null) 'tags': tags,
if (isPublished != null) 'isPublished': isPublished,
if (stock != null) 'stock': stock,
if (workoutSplitId != null) 'workoutSplitId': workoutSplitId,
if (userConfirmedSplit != null) 'userConfirmedSplit': userConfirmedSplit,
if (workoutTemplateLinks != null)
'workoutTemplateLinks': workoutTemplateLinks!.map((l) => l.toJson()).toList(),
if (weeklyModifiers != null)
'weeklyModifiers': weeklyModifiers!.map((m) => m.toJson()).toList(),
};
}
class ProgramTemplateLinkInput {
final String programWorkoutTemplateId;
final int order;
ProgramTemplateLinkInput({
required this.programWorkoutTemplateId,
required this.order,
});
Map<String, dynamic> toJson() => {
'programWorkoutTemplateId': programWorkoutTemplateId,
'order': order,
};
}
class WeeklyModifierInput {
final int weekNumber;
final String? defaultModifierId;
final String? name;
final String? description;
final double? rpeMultiplier;
final double? repsMultiplier;
final double? weightMultiplier;
WeeklyModifierInput({
required this.weekNumber,
this.defaultModifierId,
this.name,
this.description,
this.rpeMultiplier,
this.repsMultiplier,
this.weightMultiplier,
});
Map<String, dynamic> toJson() => {
'weekNumber': weekNumber,
if (defaultModifierId != null) 'defaultModifierId': defaultModifierId,
if (name != null) 'name': name,
if (description != null) 'description': description,
if (rpeMultiplier != null) 'rpeMultiplier': rpeMultiplier,
if (repsMultiplier != null) 'repsMultiplier': repsMultiplier,
if (weightMultiplier != null) 'weightMultiplier': weightMultiplier,
};
}
class UpdateProgramPlanInput {
final String? name;
final String? description;
final String? trainingFocusId;
final TrainingLevel? level;
final TrainingGoal? goal;
final int? durationWeeks;
final int? daysPerWeek;
final String? version;
final String? author;
final String? coverImageFileKey;
final Visibility? visibility;
final ProgramType? programType;
final bool? loopWeekly;
final List<String>? equipment;
final List<String>? tags;
final bool? isPublished;
final bool? stock;
final String? workoutSplitId;
final bool? userConfirmedSplit;
final List<ProgramTemplateLinkInput>? workoutTemplateLinks;
final List<WeeklyModifierInput>? weeklyModifiers;
UpdateProgramPlanInput({
this.name,
this.description,
this.trainingFocusId,
this.level,
this.goal,
this.durationWeeks,
this.daysPerWeek,
this.version,
this.author,
this.coverImageFileKey,
this.visibility,
this.programType,
this.loopWeekly,
this.equipment,
this.tags,
this.isPublished,
this.stock,
this.workoutSplitId,
this.userConfirmedSplit,
this.workoutTemplateLinks,
this.weeklyModifiers,
});
Map<String, dynamic> toJson() => {
if (name != null) 'name': name,
if (description != null) 'description': description,
if (trainingFocusId != null) 'trainingFocusId': trainingFocusId,
if (level != null) 'level': level!.name.toUpperCase(),
if (goal != null) 'goal': goal!.name.toUpperCase(),
if (durationWeeks != null) 'durationWeeks': durationWeeks,
if (daysPerWeek != null) 'daysPerWeek': daysPerWeek,
if (version != null) 'version': version,
if (author != null) 'author': author,
if (coverImageFileKey != null) 'coverImageFileKey': coverImageFileKey,
if (visibility != null) 'visibility': visibility!.name.toUpperCase(),
if (programType != null) 'programType': programType!.name.toUpperCase(),
if (loopWeekly != null) 'loopWeekly': loopWeekly,
if (equipment != null) 'equipment': equipment,
if (tags != null) 'tags': tags,
if (isPublished != null) 'isPublished': isPublished,
if (stock != null) 'stock': stock,
if (workoutSplitId != null) 'workoutSplitId': workoutSplitId,
if (userConfirmedSplit != null) 'userConfirmedSplit': userConfirmedSplit,
if (workoutTemplateLinks != null)
'workoutTemplateLinks': workoutTemplateLinks!.map((l) => l.toJson()).toList(),
if (weeklyModifiers != null)
'weeklyModifiers': weeklyModifiers!.map((m) => m.toJson()).toList(),
};
}
// Recommendation model
@JsonSerializable()
class RecommendedProgram {
final String id;
final String name;
final String description;
final String? author;
final TrainingLevel level;
final TrainingGoal goal;
final int durationWeeks;
final int daysPerWeek;
final List<String> equipment;
final WorkoutSplit? workoutSplit;
final String? coverImageUrl;
final double? recommendationScore;
final List<String> recommendationReasons;
final RecommendationCriteria matchingCriteria;
RecommendedProgram({
required this.id,
required this.name,
required this.description,
this.author,
required this.level,
required this.goal,
required this.durationWeeks,
required this.daysPerWeek,
required this.equipment,
this.workoutSplit,
this.coverImageUrl,
this.recommendationScore,
required this.recommendationReasons,
required this.matchingCriteria,
});
factory RecommendedProgram.fromJson(Map<String, dynamic> json) =>
_$RecommendedProgramFromJson(json);
Map<String, dynamic> toJson() => _$RecommendedProgramToJson(this);
bool get isHighlyRecommended => (recommendationScore ?? 0) >= 0.8;
}
@JsonSerializable()
class RecommendationCriteria {
final TrainingGoal trainingGoal;
final TrainingLevel trainingLevel;
final List<String> availableEquipment;
final int trainingFrequency;
RecommendationCriteria({
required this.trainingGoal,
required this.trainingLevel,
required this.availableEquipment,
required this.trainingFrequency,
});
factory RecommendationCriteria.fromJson(Map<String, dynamic> json) =>
_$RecommendationCriteriaFromJson(json);
Map<String, dynamic> toJson() => _$RecommendationCriteriaToJson(this);
}
// Exception handling
class ProgramPlansServiceException implements Exception {
final String message;
final String code;
final int? statusCode;
ProgramPlansServiceException({
required this.message,
required this.code,
this.statusCode,
});
factory ProgramPlansServiceException.fromGraphQLError(OperationException error) {
if (error.graphqlErrors.isNotEmpty) {
final gqlError = error.graphqlErrors.first;
return ProgramPlansServiceException(
message: gqlError.message,
code: gqlError.extensions?['code'] ?? 'UNKNOWN_ERROR',
);
}
return ProgramPlansServiceException(
message: 'Network error occurred',
code: 'NETWORK_ERROR',
);
}
}
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ProgramCatalogScreen extends StatefulWidget {
@override
_ProgramCatalogScreenState createState() => _ProgramCatalogScreenState();
}
class _ProgramCatalogScreenState extends State<ProgramCatalogScreen> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
ProgramPlanFilter _currentFilter = ProgramPlanFilter(includeStock: true);
List<ProgramPlan> _programs = [];
bool _isLoading = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 20;
@override
void initState() {
super.initState();
_loadPrograms();
_scrollController.addListener(_onScroll);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Program Catalog'),
actions: [
IconButton(
icon: Icon(Icons.filter_list),
onPressed: _showFilterDialog,
),
IconButton(
icon: Icon(Icons.stars),
onPressed: () => Navigator.pushNamed(context, '/programs/recommended'),
),
],
),
body: Column(
children: [
// Search bar
Padding(
padding: EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search programs...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_resetAndLoadPrograms();
},
)
: null,
),
onChanged: _onSearchChanged,
),
),
// Active filters
if (_hasActiveFilters()) _buildActiveFilters(),
// Program grid
Expanded(
child: _buildProgramGrid(),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.pushNamed(context, '/programs/create'),
child: Icon(Icons.add),
tooltip: 'Create Program',
),
);
}
Widget _buildProgramGrid() {
if (_programs.isEmpty && _isLoading) {
return Center(child: CircularProgressIndicator());
}
if (_programs.isEmpty && !_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.fitness_center, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'No programs found',
style: Theme.of(context).textTheme.headline6,
),
Text('Try adjusting your filters or search terms'),
SizedBox(height: 16),
ElevatedButton(
onPressed: _resetFilters,
child: Text('Clear Filters'),
),
],
),
);
}
return GridView.builder(
controller: _scrollController,
padding: EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: _programs.length + (_isLoading ? 2 : 0),
itemBuilder: (context, index) {
if (index >= _programs.length) {
return Card(
child: Center(child: CircularProgressIndicator()),
);
}
final program = _programs[index];
return _buildProgramCard(program);
},
);
}
Widget _buildProgramCard(ProgramPlan program) {
return Card(
elevation: 4,
child: InkWell(
onTap: () => Navigator.pushNamed(
context,
'/programs/detail',
arguments: program.id,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Cover image
Expanded(
flex: 3,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(8)),
color: Colors.grey[200],
),
child: program.coverImageUrl != null
? ClipRRect(
borderRadius: BorderRadius.vertical(top: Radius.circular(8)),
child: Image.network(
program.coverImageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPlaceholderImage(),
),
)
: _buildPlaceholderImage(),
),
),
// Program info
Expanded(
flex: 2,
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title and badges
Row(
children: [
Expanded(
child: Text(
program.name,
style: Theme.of(context).textTheme.subtitle1?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (program.isOfficial)
Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'OFFICIAL',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
SizedBox(height: 4),
// Author
if (program.author != null)
Text(
'by ${program.author}',
style: Theme.of(context).textTheme.caption,
),
SizedBox(height: 8),
// Program details
Row(
children: [
Icon(Icons.schedule, size: 14, color: Colors.grey[600]),
SizedBox(width: 4),
Text(
program.durationDisplay,
style: Theme.of(context).textTheme.caption,
),
SizedBox(width: 12),
Icon(Icons.fitness_center, size: 14, color: Colors.grey[600]),
SizedBox(width: 4),
Text(
program.frequencyDisplay,
style: Theme.of(context).textTheme.caption,
),
],
),
SizedBox(height: 4),
// Level and goal
Row(
children: [
_buildLevelChip(program.level),
SizedBox(width: 8),
_buildGoalChip(program.goal),
],
),
],
),
),
),
],
),
),
);
}
Widget _buildPlaceholderImage() {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.blue[300]!, Colors.blue[600]!],
),
),
child: Center(
child: Icon(
Icons.fitness_center,
size: 40,
color: Colors.white,
),
),
);
}
Widget _buildLevelChip(TrainingLevel level) {
Color color;
switch (level) {
case TrainingLevel.beginner:
color = Colors.green;
break;
case TrainingLevel.intermediate:
color = Colors.orange;
break;
case TrainingLevel.advanced:
color = Colors.red;
break;
}
return Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
border: Border.all(color: color),
borderRadius: BorderRadius.circular(12),
),
child: Text(
level.name.toUpperCase(),
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildGoalChip(TrainingGoal goal) {
final goalName = goal.name.replaceAll(RegExp(r'([A-Z])'), ' \$1').trim();
return Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
),
child: Text(
goalName.toUpperCase(),
style: TextStyle(
color: Colors.grey[700],
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildActiveFilters() {
final activeFilters = <Widget>[];
if (_currentFilter.level != null) {
activeFilters.add(_buildFilterChip('Level: ${_currentFilter.level!.name}'));
}
if (_currentFilter.goal != null) {
activeFilters.add(_buildFilterChip('Goal: ${_currentFilter.goal!.name}'));
}
if (_currentFilter.daysPerWeek != null) {
activeFilters.add(_buildFilterChip('${_currentFilter.daysPerWeek} days/week'));
}
if (_currentFilter.equipment?.isNotEmpty == true) {
activeFilters.add(_buildFilterChip('${_currentFilter.equipment!.length} equipment'));
}
return Container(
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16),
children: [
...activeFilters,
if (activeFilters.isNotEmpty)
Padding(
padding: EdgeInsets.only(left: 8),
child: ActionChip(
label: Text('Clear All'),
onPressed: _resetFilters,
backgroundColor: Colors.red[50],
labelStyle: TextStyle(color: Colors.red),
),
),
],
),
);
}
Widget _buildFilterChip(String label) {
return Padding(
padding: EdgeInsets.only(right: 8),
child: Chip(
label: Text(label),
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
labelStyle: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 12,
),
),
);
}
void _showFilterDialog() async {
final result = await showDialog<ProgramPlanFilter>(
context: context,
builder: (context) => ProgramFilterDialog(
initialFilter: _currentFilter,
),
);
if (result != null) {
setState(() {
_currentFilter = result;
});
_resetAndLoadPrograms();
}
}
Future<void> _loadPrograms() async {
if (_isLoading) return;
setState(() => _isLoading = true);
try {
final newPrograms = await context.read<ProgramPlansService>().getProgramPlans(
filter: _currentFilter,
limit: _pageSize,
offset: _currentOffset,
);
setState(() {
if (_currentOffset == 0) {
_programs = newPrograms;
} else {
_programs.addAll(newPrograms);
}
_currentOffset += newPrograms.length;
_hasMore = newPrograms.length == _pageSize;
});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load programs: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
void _resetAndLoadPrograms() {
setState(() {
_programs.clear();
_currentOffset = 0;
_hasMore = true;
});
_loadPrograms();
}
void _onSearchChanged(String value) {
// Debounce search
Future.delayed(Duration(milliseconds: 500), () {
if (_searchController.text == value) {
setState(() {
_currentFilter = _currentFilter.copyWith(search: value.isEmpty ? null : value);
});
_resetAndLoadPrograms();
}
});
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) {
if (_hasMore && !_isLoading) {
_loadPrograms();
}
}
}
bool _hasActiveFilters() {
return _currentFilter.level != null ||
_currentFilter.goal != null ||
_currentFilter.daysPerWeek != null ||
_currentFilter.equipment?.isNotEmpty == true ||
_currentFilter.search?.isNotEmpty == true;
}
void _resetFilters() {
setState(() {
_currentFilter = ProgramPlanFilter(includeStock: true);
_searchController.clear();
});
_resetAndLoadPrograms();
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
}
// Filter dialog component
class ProgramFilterDialog extends StatefulWidget {
final ProgramPlanFilter initialFilter;
ProgramFilterDialog({required this.initialFilter});
@override
_ProgramFilterDialogState createState() => _ProgramFilterDialogState();
}
class _ProgramFilterDialogState extends State<ProgramFilterDialog> {
late ProgramPlanFilter _filter;
@override
void initState() {
super.initState();
_filter = widget.initialFilter;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Filter Programs'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Training level filter
DropdownButtonFormField<TrainingLevel>(
value: _filter.level,
decoration: InputDecoration(
labelText: 'Training Level',
border: OutlineInputBorder(),
),
items: [
DropdownMenuItem(value: null, child: Text('Any Level')),
...TrainingLevel.values.map((level) => DropdownMenuItem(
value: level,
child: Text(level.name.toLowerCase().capitalize()),
)),
],
onChanged: (value) => setState(() =>
_filter = _filter.copyWith(level: value)),
),
SizedBox(height: 16),
// Training goal filter
DropdownButtonFormField<TrainingGoal>(
value: _filter.goal,
decoration: InputDecoration(
labelText: 'Training Goal',
border: OutlineInputBorder(),
),
items: [
DropdownMenuItem(value: null, child: Text('Any Goal')),
...TrainingGoal.values.map((goal) => DropdownMenuItem(
value: goal,
child: Text(goal.displayName),
)),
],
onChanged: (value) => setState(() =>
_filter = _filter.copyWith(goal: value)),
),
SizedBox(height: 16),
// Days per week slider
Text('Training Frequency: ${_filter.daysPerWeek ?? "Any"} days/week'),
Slider(
value: (_filter.daysPerWeek ?? 0).toDouble(),
min: 0,
max: 7,
divisions: 7,
label: _filter.daysPerWeek?.toString() ?? 'Any',
onChanged: (value) => setState(() =>
_filter = _filter.copyWith(
daysPerWeek: value == 0 ? null : value.toInt())),
),
SizedBox(height: 16),
// Program types
Text('Program Types:', style: TextStyle(fontWeight: FontWeight.bold)),
CheckboxListTile(
title: Text('Official Programs'),
value: _filter.includeStock ?? false,
onChanged: (value) => setState(() =>
_filter = _filter.copyWith(includeStock: value)),
),
CheckboxListTile(
title: Text('Community Programs'),
value: _filter.includeCommunity ?? false,
onChanged: (value) => setState(() =>
_filter = _filter.copyWith(includeCommunity: value)),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(_filter),
child: Text('Apply'),
),
],
);
}
}
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${this.substring(1)}";
}
}
extension TrainingGoalExtension on TrainingGoal {
String get displayName {
switch (this) {
case TrainingGoal.muscleGain:
return 'Muscle Gain';
case TrainingGoal.strengthGain:
return 'Strength Gain';
case TrainingGoal.weightLoss:
return 'Weight Loss';
case TrainingGoal.endurance:
return 'Endurance';
case TrainingGoal.generalFitness:
return 'General Fitness';
}
}
}
🛡️ Business Logic & Rules
Program Plan Validation Rules
The system enforces comprehensive validation rules for program integrity:
interface ProgramPlanValidationRules {
// Required fields validation
name: {
minLength: 3,
maxLength: 100,
pattern: /^[a-zA-Z0-9\s\-_]+$/
},
description: {
minLength: 10,
maxLength: 1000
},
// Program structure validation
durationWeeks: {
min: 1,
max: 52 // Maximum 1 year programs
},
daysPerWeek: {
min: 1,
max: 7
},
// Weekly modifiers validation
weeklyModifiers: {
uniqueWeekNumbers: true,
weekNumberRange: [1, durationWeeks],
multiplierRanges: {
rpeMultiplier: [0.3, 1.2],
repsMultiplier: [0.5, 2.0],
weightMultiplier: [0.5, 1.5]
}
},
// Template links validation
workoutTemplateLinks: {
uniqueOrder: true,
validTemplateIds: true,
maxTemplates: 20
}
}
Program Recommendation Algorithm
The AI-powered recommendation system uses multiple factors:
-
User Profile Matching (40% weight)
- Training goal alignment
- Training level appropriateness
- Experience and preferences
-
Equipment Availability (30% weight)
- Required equipment vs available equipment
- Equipment complexity matching user level
-
Schedule Compatibility (20% weight)
- Days per week matching user frequency preference
- Program duration fitting user commitment
-
Historical Performance (10% weight)
- Similar program completion rates
- User feedback on similar programs
- Community ratings and reviews
Access Control & Visibility
Program access follows a hierarchical visibility model:
enum ProgramVisibility {
PUBLIC = "PUBLIC", // Visible to all users in catalog
PRIVATE = "PRIVATE", // Only visible to creator
SHARED = "SHARED" // Accessible via direct link sharing
}
enum ProgramType {
STOCK = "STOCK", // OpenLift official programs
CUSTOM = "CUSTOM", // User-created programs
COMMUNITY = "COMMUNITY" // Community-contributed programs
}
interface AccessRules {
canView: (user: User, program: ProgramPlan) => boolean;
canEdit: (user: User, program: ProgramPlan) => boolean;
canDelete: (user: User, program: ProgramPlan) => boolean;
canPublish: (user: User, program: ProgramPlan) => boolean;
}
🔄 State Management
Program Plans Provider
import 'package:flutter/material.dart';
class ProgramPlansProvider extends ChangeNotifier {
final ProgramPlansService _service;
List<ProgramPlan> _programs = [];
List<RecommendedProgram> _recommendations = [];
ProgramPlanFilter _currentFilter = ProgramPlanFilter();
bool _isLoading = false;
String? _error;
List<ProgramPlan> get programs => _programs;
List<RecommendedProgram> get recommendations => _recommendations;
ProgramPlanFilter get currentFilter => _currentFilter;
bool get isLoading => _isLoading;
String? get error => _error;
ProgramPlansProvider(this._service);
Future<void> loadPrograms({
ProgramPlanFilter? filter,
bool refresh = false,
}) async {
if (refresh) _programs.clear();
_setLoading(true);
_currentFilter = filter ?? _currentFilter;
try {
final newPrograms = await _service.getProgramPlans(
filter: _currentFilter,
limit: 20,
offset: refresh ? 0 : _programs.length,
);
if (refresh) {
_programs = newPrograms;
} else {
_programs.addAll(newPrograms);
}
_error = null;
} catch (e) {
_error = e.toString();
} finally {
_setLoading(false);
}
}
Future<void> loadRecommendations() async {
_setLoading(true);
try {
_recommendations = await _service.getRecommendedPrograms(limit: 10);
_error = null;
} catch (e) {
_error = e.toString();
} finally {
_setLoading(false);
}
}
Future<ProgramPlan> createProgram(CreateProgramPlanInput input) async {
_setLoading(true);
try {
final newProgram = await _service.createProgramPlan(input);
_programs.insert(0, newProgram);
_error = null;
return newProgram;
} catch (e) {
_error = e.toString();
rethrow;
} finally {
_setLoading(false);
}
}
Future<void> updateProgram(
String programId,
UpdateProgramPlanInput input,
) async {
try {
final updatedProgram = await _service.updateProgramPlan(programId, input);
final index = _programs.indexWhere((p) => p.id == programId);
if (index != -1) {
_programs[index] = updatedProgram;
notifyListeners();
}
} catch (e) {
_error = e.toString();
notifyListeners();
rethrow;
}
}
Future<void> deleteProgram(String programId) async {
try {
await _service.deleteProgramPlan(programId);
_programs.removeWhere((p) => p.id == programId);
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
rethrow;
}
}
void updateFilter(ProgramPlanFilter filter) {
_currentFilter = filter;
loadPrograms(filter: filter, refresh: true);
}
void clearError() {
_error = null;
notifyListeners();
}
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
}
🚀 Testing
Unit Tests
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
void main() {
group('ProgramPlansService', () {
late MockGraphQLClient mockClient;
late ProgramPlansService service;
setUp(() {
mockClient = MockGraphQLClient();
service = ProgramPlansService(mockClient);
});
test('should return program plans with filtering', () async {
// Arrange
final mockData = {
'programPlans': [
{
'id': 'program1',
'name': 'Beginner Strength',
'level': 'BEGINNER',
'goal': 'STRENGTH_GAIN',
// ... other fields
}
]
};
when(mockClient.query(any)).thenAnswer((_) async => QueryResult(
data: mockData,
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Act
final filter = ProgramPlanFilter(level: TrainingLevel.beginner);
final result = await service.getProgramPlans(filter: filter);
// Assert
expect(result.length, equals(1));
expect(result.first.name, equals('Beginner Strength'));
expect(result.first.level, equals(TrainingLevel.beginner));
// Verify GraphQL call
final capturedOptions = verify(mockClient.query(captureAny)).captured.single as QueryOptions;
final variables = capturedOptions.variables;
expect(variables['filter']['level'], equals('BEGINNER'));
});
test('should create program plan successfully', () async {
// Test implementation for program creation
});
test('should handle program recommendations', () async {
// Test implementation for recommendations
});
});
}
📚 Migration Notes
- All program IDs are MongoDB ObjectId strings
- Weekly modifiers use multiplier-based progression (not additive)
- Equipment requirements stored as string arrays for flexibility
- Cover images use signed URLs with expiration
- Program structure supports both linear and block periodization
- Recommendation scoring uses machine learning algorithms (server-side)
- Program types determine access and visibility rules
🆘 Support
For program plans questions:
- Check program structure validation rules for creation issues
- Verify equipment filtering logic matches user's available equipment
- Ensure recommendation algorithm considers all user preferences
- Contact backend team for training focus and workout split questions
Ready to manage program catalogs? Check out the Program User Instance API for user program enrollment and scheduling.