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

Workout Split API

The Workout Split API provides comprehensive workout organization and intelligent split detection services for the OpenLift platform. This system helps users and coaches organize training programs into logical workout splits (Push/Pull/Legs, Upper/Lower, Full Body, etc.) and automatically detects optimal splits based on exercise patterns.

Overview

OpenLift's workout split system serves two primary functions:

  1. Split Management: Browse, create, and manage workout split templates that define how training is organized throughout the week
  2. Intelligent Detection: AI-powered analysis that recommends optimal workout splits based on program structure or exercise selection

The system supports both system-defined splits (curated by fitness experts) and user-created custom splits, with intelligent filtering and recommendation capabilities.

Core Concepts

Workout Split

A Workout Split defines how training is organized across the week, specifying which muscle groups are trained on which days:

interface WorkoutSplit {
id: string;
name: string; // e.g., "Push Pull Legs"
description?: string; // Detailed explanation
daysPerWeek: number; // Training frequency
difficulty: TrainingLevel; // BEGINNER | INTERMEDIATE | ADVANCED
muscleGroupRotation: string[]; // Ordered muscle group schedule
isSystemDefault: boolean; // Expert-curated vs user-created
popularityScore: number; // Usage-based ranking
isActive: boolean; // Availability status
programCount: number; // Programs using this split
}

Split Detection Analysis

Split Detection analyzes exercise patterns to recommend optimal workout organization:

interface SplitDetectionResult {
suggestedSplitId: string | null; // Best matching split
confidence: number; // 0.0 to 1.0 confidence score
reasoning: string; // Explanation of recommendation
alternativeSplits: AlternativeSplit[]; // Additional options
}

interface ExercisePattern {
exerciseId: string;
muscleGroups: string[]; // All targeted muscle groups
primaryMuscleGroup: string; // Primary focus
force?: 'PUSH' | 'PULL' | 'STATIC'; // Movement pattern
dayIndex?: number; // Program day assignment
}

GraphQL Schema

Query Operations

Get All Workout Splits

query GetWorkoutSplits(
$filter: WorkoutSplitFilterInput
$orderBy: WorkoutSplitOrderByInput
$limit: Int
$offset: Int
) {
workoutSplits(
filter: $filter
orderBy: $orderBy
limit: $limit
offset: $offset
) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
isSystemDefault
popularityScore
isActive
createdAt
updatedAt
programCount
createdBy {
id
firstName
lastName
}
programPlans {
id
name
}
}
}

Get Single Workout Split

query GetWorkoutSplit($id: ID!) {
workoutSplit(id: $id) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
isSystemDefault
popularityScore
isActive
programCount
createdBy {
id
firstName
lastName
}
}
}
query GetPopularWorkoutSplits($limit: Int) {
popularWorkoutSplits(limit: $limit) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
popularityScore
programCount
}
}

Get Splits by Difficulty Level

query GetWorkoutSplitsByDifficulty($difficulty: TrainingLevel!, $limit: Int) {
workoutSplitsByDifficulty(difficulty: $difficulty, limit: $limit) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
popularityScore
programCount
}
}

Mutation Operations

Create Custom Workout Split

mutation CreateWorkoutSplit($input: CreateWorkoutSplitInput!) {
createWorkoutSplit(input: $input) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
isSystemDefault
popularityScore
isActive
createdAt
createdBy {
id
firstName
lastName
}
}
}

Update Workout Split

mutation UpdateWorkoutSplit($input: UpdateWorkoutSplitInput!) {
updateWorkoutSplit(input: $input) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
popularityScore
isActive
updatedAt
}
}

Delete Workout Split

mutation DeleteWorkoutSplit($id: ID!) {
deleteWorkoutSplit(id: $id)
}

Input Types

Workout Split Filter

input WorkoutSplitFilterInput {
difficulty: TrainingLevel # Filter by difficulty level
daysPerWeek: Int # Filter by training frequency
isSystemDefault: Boolean # System vs user-created splits
search: String # Search name and description
}

Workout Split Ordering

input WorkoutSplitOrderByInput {
popularityScore: SortOrder # Order by usage popularity
name: SortOrder # Alphabetical ordering
daysPerWeek: SortOrder # Order by frequency
createdAt: SortOrder # Order by creation date
}

Create Workout Split

input CreateWorkoutSplitInput {
name: String! # Split name
description: String # Optional description
daysPerWeek: Int! # Training days per week
difficulty: TrainingLevel! # Target difficulty level
muscleGroupRotation: [String!]! # Muscle group schedule
}

Update Workout Split

input UpdateWorkoutSplitInput {
id: ID! # Split to update
name: String # Updated name
description: String # Updated description
daysPerWeek: Int # Updated frequency
difficulty: TrainingLevel # Updated difficulty
muscleGroupRotation: [String!] # Updated schedule
popularityScore: Int # Admin: update popularity
isActive: Boolean # Admin: activate/deactivate
}

Enums

enum TrainingLevel {
BEGINNER
INTERMEDIATE
ADVANCED
}

enum SortOrder {
ASC
DESC
}

Authentication & Authorization

Workout split operations have varying authentication requirements:

// Public access for browsing splits
final publicHeaders = {
'Content-Type': 'application/json',
};

// Authentication required for creating/modifying splits
final authHeaders = {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
};

Flutter Integration

Setting Up GraphQL Operations

1. Define GraphQL Documents

// lib/graphql/workout_split_queries.dart
const String GET_WORKOUT_SPLITS = '''
query GetWorkoutSplits(
\$filter: WorkoutSplitFilterInput
\$orderBy: WorkoutSplitOrderByInput
\$limit: Int
\$offset: Int
) {
workoutSplits(
filter: \$filter
orderBy: \$orderBy
limit: \$limit
offset: \$offset
) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
isSystemDefault
popularityScore
programCount
createdBy {
id
firstName
lastName
}
}
}
''';

const String GET_POPULAR_WORKOUT_SPLITS = '''
query GetPopularWorkoutSplits(\$limit: Int) {
popularWorkoutSplits(limit: \$limit) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
popularityScore
programCount
}
}
''';

const String GET_WORKOUT_SPLITS_BY_DIFFICULTY = '''
query GetWorkoutSplitsByDifficulty(\$difficulty: TrainingLevel!, \$limit: Int) {
workoutSplitsByDifficulty(difficulty: \$difficulty, limit: \$limit) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
popularityScore
programCount
}
}
''';

const String CREATE_WORKOUT_SPLIT = '''
mutation CreateWorkoutSplit(\$input: CreateWorkoutSplitInput!) {
createWorkoutSplit(input: \$input) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
isSystemDefault
popularityScore
createdAt
}
}
''';

const String UPDATE_WORKOUT_SPLIT = '''
mutation UpdateWorkoutSplit(\$input: UpdateWorkoutSplitInput!) {
updateWorkoutSplit(input: \$input) {
id
name
description
daysPerWeek
difficulty
muscleGroupRotation
popularityScore
isActive
updatedAt
}
}
''';

const String DELETE_WORKOUT_SPLIT = '''
mutation DeleteWorkoutSplit(\$id: ID!) {
deleteWorkoutSplit(id: \$id)
}
''';

2. Create Data Models

// lib/models/workout_split.dart
enum TrainingLevel { beginner, intermediate, advanced }

class WorkoutSplit {
final String id;
final String name;
final String? description;
final int daysPerWeek;
final TrainingLevel difficulty;
final List<String> muscleGroupRotation;
final bool isSystemDefault;
final int popularityScore;
final bool isActive;
final int programCount;
final DateTime createdAt;
final DateTime updatedAt;
final User? createdBy;

WorkoutSplit({
required this.id,
required this.name,
this.description,
required this.daysPerWeek,
required this.difficulty,
required this.muscleGroupRotation,
required this.isSystemDefault,
required this.popularityScore,
required this.isActive,
required this.programCount,
required this.createdAt,
required this.updatedAt,
this.createdBy,
});

factory WorkoutSplit.fromJson(Map<String, dynamic> json) {
return WorkoutSplit(
id: json['id'],
name: json['name'],
description: json['description'],
daysPerWeek: json['daysPerWeek'],
difficulty: _parseTrainingLevel(json['difficulty']),
muscleGroupRotation: List<String>.from(json['muscleGroupRotation']),
isSystemDefault: json['isSystemDefault'],
popularityScore: json['popularityScore'],
isActive: json['isActive'] ?? true,
programCount: json['programCount'] ?? 0,
createdAt: DateTime.parse(json['createdAt']),
updatedAt: DateTime.parse(json['updatedAt']),
createdBy: json['createdBy'] != null ? User.fromJson(json['createdBy']) : null,
);
}

static TrainingLevel _parseTrainingLevel(String level) {
switch (level.toLowerCase()) {
case 'beginner':
return TrainingLevel.beginner;
case 'intermediate':
return TrainingLevel.intermediate;
case 'advanced':
return TrainingLevel.advanced;
default:
return TrainingLevel.beginner;
}
}

String get difficultyDisplayName {
switch (difficulty) {
case TrainingLevel.beginner:
return 'Beginner';
case TrainingLevel.intermediate:
return 'Intermediate';
case TrainingLevel.advanced:
return 'Advanced';
}
}

String get daysPerWeekText {
return '$daysPerWeek ${daysPerWeek == 1 ? 'day' : 'days'} per week';
}

String get muscleGroupsText {
return muscleGroupRotation.join(' → ');
}

bool get isCustom => !isSystemDefault;
}

class WorkoutSplitFilter {
final TrainingLevel? difficulty;
final int? daysPerWeek;
final bool? isSystemDefault;
final String? search;

WorkoutSplitFilter({
this.difficulty,
this.daysPerWeek,
this.isSystemDefault,
this.search,
});

Map<String, dynamic> toJson() {
final result = <String, dynamic>{};
if (difficulty != null) result['difficulty'] = difficulty!.name.toUpperCase();
if (daysPerWeek != null) result['daysPerWeek'] = daysPerWeek;
if (isSystemDefault != null) result['isSystemDefault'] = isSystemDefault;
if (search != null && search!.isNotEmpty) result['search'] = search;
return result;
}
}

class WorkoutSplitOrderBy {
final String? popularityScore;
final String? name;
final String? daysPerWeek;
final String? createdAt;

WorkoutSplitOrderBy({
this.popularityScore,
this.name,
this.daysPerWeek,
this.createdAt,
});

Map<String, dynamic> toJson() {
final result = <String, dynamic>{};
if (popularityScore != null) result['popularityScore'] = popularityScore;
if (name != null) result['name'] = name;
if (daysPerWeek != null) result['daysPerWeek'] = daysPerWeek;
if (createdAt != null) result['createdAt'] = createdAt;
return result;
}
}

3. Create Service Class

// lib/services/workout_split_service.dart
import 'package:graphql_flutter/graphql_flutter.dart';
import '../graphql/workout_split_queries.dart';
import '../models/workout_split.dart';

class WorkoutSplitService {
final GraphQLClient _client;

WorkoutSplitService(this._client);

/// Get all workout splits with optional filtering and ordering
Future<List<WorkoutSplit>> getWorkoutSplits({
WorkoutSplitFilter? filter,
WorkoutSplitOrderBy? orderBy,
int? limit,
int? offset,
}) async {
final options = QueryOptions(
document: gql(GET_WORKOUT_SPLITS),
variables: {
if (filter != null) 'filter': filter.toJson(),
if (orderBy != null) 'orderBy': orderBy.toJson(),
if (limit != null) 'limit': limit,
if (offset != null) 'offset': offset,
},
errorPolicy: ErrorPolicy.all,
);

final result = await _client.query(options);

if (result.hasException) {
throw result.exception!;
}

final data = result.data?['workoutSplits'] as List? ?? [];
return data.map((e) => WorkoutSplit.fromJson(e)).toList();
}

/// Get popular workout splits
Future<List<WorkoutSplit>> getPopularWorkoutSplits({int limit = 5}) async {
final options = QueryOptions(
document: gql(GET_POPULAR_WORKOUT_SPLITS),
variables: {'limit': limit},
errorPolicy: ErrorPolicy.all,
);

final result = await _client.query(options);

if (result.hasException) {
throw result.exception!;
}

final data = result.data?['popularWorkoutSplits'] as List? ?? [];
return data.map((e) => WorkoutSplit.fromJson(e)).toList();
}

/// Get workout splits by difficulty level
Future<List<WorkoutSplit>> getWorkoutSplitsByDifficulty({
required TrainingLevel difficulty,
int? limit,
}) async {
final options = QueryOptions(
document: gql(GET_WORKOUT_SPLITS_BY_DIFFICULTY),
variables: {
'difficulty': difficulty.name.toUpperCase(),
if (limit != null) 'limit': limit,
},
errorPolicy: ErrorPolicy.all,
);

final result = await _client.query(options);

if (result.hasException) {
throw result.exception!;
}

final data = result.data?['workoutSplitsByDifficulty'] as List? ?? [];
return data.map((e) => WorkoutSplit.fromJson(e)).toList();
}

/// Create a custom workout split
Future<WorkoutSplit> createWorkoutSplit({
required String name,
String? description,
required int daysPerWeek,
required TrainingLevel difficulty,
required List<String> muscleGroupRotation,
}) async {
final options = MutationOptions(
document: gql(CREATE_WORKOUT_SPLIT),
variables: {
'input': {
'name': name,
if (description != null) 'description': description,
'daysPerWeek': daysPerWeek,
'difficulty': difficulty.name.toUpperCase(),
'muscleGroupRotation': muscleGroupRotation,
},
},
errorPolicy: ErrorPolicy.all,
);

final result = await _client.mutate(options);

if (result.hasException) {
throw result.exception!;
}

final data = result.data?['createWorkoutSplit'];
if (data == null) {
throw Exception('No workout split data received');
}

return WorkoutSplit.fromJson(data);
}

/// Update a workout split
Future<WorkoutSplit> updateWorkoutSplit({
required String id,
String? name,
String? description,
int? daysPerWeek,
TrainingLevel? difficulty,
List<String>? muscleGroupRotation,
int? popularityScore,
bool? isActive,
}) async {
final options = MutationOptions(
document: gql(UPDATE_WORKOUT_SPLIT),
variables: {
'input': {
'id': id,
if (name != null) 'name': name,
if (description != null) 'description': description,
if (daysPerWeek != null) 'daysPerWeek': daysPerWeek,
if (difficulty != null) 'difficulty': difficulty.name.toUpperCase(),
if (muscleGroupRotation != null) 'muscleGroupRotation': muscleGroupRotation,
if (popularityScore != null) 'popularityScore': popularityScore,
if (isActive != null) 'isActive': isActive,
},
},
errorPolicy: ErrorPolicy.all,
);

final result = await _client.mutate(options);

if (result.hasException) {
throw result.exception!;
}

final data = result.data?['updateWorkoutSplit'];
if (data == null) {
throw Exception('No updated workout split data received');
}

return WorkoutSplit.fromJson(data);
}

/// Delete a workout split
Future<bool> deleteWorkoutSplit(String id) async {
final options = MutationOptions(
document: gql(DELETE_WORKOUT_SPLIT),
variables: {'id': id},
errorPolicy: ErrorPolicy.all,
);

final result = await _client.mutate(options);

if (result.hasException) {
throw result.exception!;
}

return result.data?['deleteWorkoutSplit'] == true;
}
}

4. State Management with Provider

// lib/providers/workout_split_provider.dart
import 'package:flutter/foundation.dart';
import '../services/workout_split_service.dart';
import '../models/workout_split.dart';

class WorkoutSplitProvider extends ChangeNotifier {
final WorkoutSplitService _service;

List<WorkoutSplit> _allSplits = [];
List<WorkoutSplit> _popularSplits = [];
List<WorkoutSplit> _filteredSplits = [];
bool _isLoading = false;
String? _errorMessage;

// Current filter state
WorkoutSplitFilter _currentFilter = WorkoutSplitFilter();
WorkoutSplitOrderBy _currentOrderBy = WorkoutSplitOrderBy(popularityScore: 'DESC');

WorkoutSplitProvider(this._service);

// Getters
List<WorkoutSplit> get allSplits => _allSplits;
List<WorkoutSplit> get popularSplits => _popularSplits;
List<WorkoutSplit> get filteredSplits => _filteredSplits;
List<WorkoutSplit> get systemSplits => _allSplits.where((split) => split.isSystemDefault).toList();
List<WorkoutSplit> get customSplits => _allSplits.where((split) => !split.isSystemDefault).toList();
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
WorkoutSplitFilter get currentFilter => _currentFilter;

/// Load all workout splits
Future<void> loadAllSplits() async {
_isLoading = true;
_errorMessage = null;
notifyListeners();

try {
_allSplits = await _service.getWorkoutSplits(
orderBy: _currentOrderBy,
);
_filteredSplits = List.from(_allSplits);
} catch (e) {
_errorMessage = e.toString();
_allSplits = [];
_filteredSplits = [];
} finally {
_isLoading = false;
notifyListeners();
}
}

/// Load popular workout splits
Future<void> loadPopularSplits() async {
try {
_popularSplits = await _service.getPopularWorkoutSplits();
notifyListeners();
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
}
}

/// Apply filters to workout splits
Future<void> applyFilter(WorkoutSplitFilter filter) async {
_currentFilter = filter;
_isLoading = true;
notifyListeners();

try {
_filteredSplits = await _service.getWorkoutSplits(
filter: filter,
orderBy: _currentOrderBy,
);
} catch (e) {
_errorMessage = e.toString();
_filteredSplits = [];
} finally {
_isLoading = false;
notifyListeners();
}
}

/// Clear all filters
void clearFilters() {
_currentFilter = WorkoutSplitFilter();
_filteredSplits = List.from(_allSplits);
notifyListeners();
}

/// Search workout splits
Future<void> searchSplits(String query) async {
if (query.isEmpty) {
clearFilters();
return;
}

await applyFilter(WorkoutSplitFilter(search: query));
}

/// Filter by difficulty level
Future<void> filterByDifficulty(TrainingLevel? difficulty) async {
await applyFilter(WorkoutSplitFilter(
difficulty: difficulty,
daysPerWeek: _currentFilter.daysPerWeek,
isSystemDefault: _currentFilter.isSystemDefault,
search: _currentFilter.search,
));
}

/// Filter by days per week
Future<void> filterByDaysPerWeek(int? daysPerWeek) async {
await applyFilter(WorkoutSplitFilter(
difficulty: _currentFilter.difficulty,
daysPerWeek: daysPerWeek,
isSystemDefault: _currentFilter.isSystemDefault,
search: _currentFilter.search,
));
}

/// Filter by split type (system vs custom)
Future<void> filterByType(bool? isSystemDefault) async {
await applyFilter(WorkoutSplitFilter(
difficulty: _currentFilter.difficulty,
daysPerWeek: _currentFilter.daysPerWeek,
isSystemDefault: isSystemDefault,
search: _currentFilter.search,
));
}

/// Create a new workout split
Future<void> createSplit({
required String name,
String? description,
required int daysPerWeek,
required TrainingLevel difficulty,
required List<String> muscleGroupRotation,
}) async {
try {
final newSplit = await _service.createWorkoutSplit(
name: name,
description: description,
daysPerWeek: daysPerWeek,
difficulty: difficulty,
muscleGroupRotation: muscleGroupRotation,
);

_allSplits.add(newSplit);
_allSplits.sort((a, b) => b.popularityScore.compareTo(a.popularityScore));

// Update filtered splits if the new split matches current filter
if (_matchesCurrentFilter(newSplit)) {
_filteredSplits.add(newSplit);
_filteredSplits.sort((a, b) => b.popularityScore.compareTo(a.popularityScore));
}

notifyListeners();
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
rethrow;
}
}

/// Update an existing workout split
Future<void> updateSplit({
required String id,
String? name,
String? description,
int? daysPerWeek,
TrainingLevel? difficulty,
List<String>? muscleGroupRotation,
}) async {
try {
final updatedSplit = await _service.updateWorkoutSplit(
id: id,
name: name,
description: description,
daysPerWeek: daysPerWeek,
difficulty: difficulty,
muscleGroupRotation: muscleGroupRotation,
);

_updateSplitInLists(updatedSplit);
notifyListeners();
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
rethrow;
}
}

/// Delete a workout split
Future<void> deleteSplit(String id) async {
try {
final success = await _service.deleteWorkoutSplit(id);
if (success) {
_allSplits.removeWhere((split) => split.id == id);
_filteredSplits.removeWhere((split) => split.id == id);
_popularSplits.removeWhere((split) => split.id == id);
notifyListeners();
}
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
rethrow;
}
}

bool _matchesCurrentFilter(WorkoutSplit split) {
if (_currentFilter.difficulty != null && split.difficulty != _currentFilter.difficulty) {
return false;
}
if (_currentFilter.daysPerWeek != null && split.daysPerWeek != _currentFilter.daysPerWeek) {
return false;
}
if (_currentFilter.isSystemDefault != null && split.isSystemDefault != _currentFilter.isSystemDefault) {
return false;
}
if (_currentFilter.search != null && _currentFilter.search!.isNotEmpty) {
final query = _currentFilter.search!.toLowerCase();
return split.name.toLowerCase().contains(query) ||
(split.description?.toLowerCase().contains(query) ?? false);
}
return true;
}

void _updateSplitInLists(WorkoutSplit updatedSplit) {
final updateList = (List<WorkoutSplit> list) {
final index = list.indexWhere((split) => split.id == updatedSplit.id);
if (index != -1) {
list[index] = updatedSplit;
}
};

updateList(_allSplits);
updateList(_filteredSplits);
updateList(_popularSplits);
}
}

5. UI Implementation

// lib/screens/workout_splits_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/workout_split_provider.dart';
import '../models/workout_split.dart';

class WorkoutSplitsScreen extends StatefulWidget {
@override
_WorkoutSplitsScreenState createState() => _WorkoutSplitsScreenState();
}

class _WorkoutSplitsScreenState extends State<WorkoutSplitsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final TextEditingController _searchController = TextEditingController();

@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadData();
});
}

Future<void> _loadData() async {
final provider = context.read<WorkoutSplitProvider>();
await Future.wait([
provider.loadAllSplits(),
provider.loadPopularSplits(),
]);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Workout Splits'),
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(text: 'Popular'),
Tab(text: 'Browse All'),
Tab(text: 'My Splits'),
],
),
actions: [
IconButton(
icon: Icon(Icons.add),
onPressed: () => _showCreateSplitDialog(),
),
],
),
body: Consumer<WorkoutSplitProvider>(
builder: (context, provider, child) {
if (provider.isLoading && provider.allSplits.isEmpty) {
return Center(child: CircularProgressIndicator());
}

if (provider.errorMessage != null) {
return _buildErrorState(provider.errorMessage!);
}

return TabBarView(
controller: _tabController,
children: [
_buildPopularTab(provider),
_buildBrowseAllTab(provider),
_buildMySplitsTab(provider),
],
);
},
),
);
}

Widget _buildPopularTab(WorkoutSplitProvider provider) {
return RefreshIndicator(
onRefresh: () => provider.loadPopularSplits(),
child: provider.popularSplits.isEmpty
? _buildEmptyState('No popular splits found', Icons.star_outline)
: ListView.builder(
padding: EdgeInsets.all(16),
itemCount: provider.popularSplits.length,
itemBuilder: (context, index) {
final split = provider.popularSplits[index];
return WorkoutSplitCard(
split: split,
onTap: () => _showSplitDetails(split),
showPopularityBadge: true,
);
},
),
);
}

Widget _buildBrowseAllTab(WorkoutSplitProvider provider) {
return Column(
children: [
_buildSearchAndFilters(provider),
Expanded(
child: RefreshIndicator(
onRefresh: () => provider.loadAllSplits(),
child: provider.filteredSplits.isEmpty
? _buildEmptyState('No splits found', Icons.search_off)
: ListView.builder(
padding: EdgeInsets.all(16),
itemCount: provider.filteredSplits.length,
itemBuilder: (context, index) {
final split = provider.filteredSplits[index];
return WorkoutSplitCard(
split: split,
onTap: () => _showSplitDetails(split),
);
},
),
),
),
],
);
}

Widget _buildMySplitsTab(WorkoutSplitProvider provider) {
final mySplits = provider.customSplits;
return RefreshIndicator(
onRefresh: () => provider.loadAllSplits(),
child: mySplits.isEmpty
? _buildEmptyState(
'No custom splits yet',
Icons.add_circle_outline,
'Create your own workout split',
() => _showCreateSplitDialog(),
)
: ListView.builder(
padding: EdgeInsets.all(16),
itemCount: mySplits.length,
itemBuilder: (context, index) {
final split = mySplits[index];
return WorkoutSplitCard(
split: split,
onTap: () => _showSplitDetails(split),
showEditOption: true,
onEdit: () => _showEditSplitDialog(split),
onDelete: () => _confirmDeleteSplit(split),
);
},
),
);
}

Widget _buildSearchAndFilters(WorkoutSplitProvider provider) {
return Container(
padding: EdgeInsets.all(16),
child: Column(
children: [
// Search bar
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search splits...',
prefixIcon: Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear),
onPressed: () {
_searchController.clear();
provider.clearFilters();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) => provider.searchSplits(query),
),
SizedBox(height: 12),
// Filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip(
'Difficulty',
provider.currentFilter.difficulty?.difficultyDisplayName,
() => _showDifficultyFilter(provider),
),
SizedBox(width: 8),
_buildFilterChip(
'Days/Week',
provider.currentFilter.daysPerWeek?.toString(),
() => _showDaysPerWeekFilter(provider),
),
SizedBox(width: 8),
_buildFilterChip(
'Type',
provider.currentFilter.isSystemDefault == true
? 'System'
: provider.currentFilter.isSystemDefault == false
? 'Custom'
: null,
() => _showTypeFilter(provider),
),
SizedBox(width: 8),
if (_hasActiveFilters(provider))
ActionChip(
label: Text('Clear All'),
onPressed: () => provider.clearFilters(),
backgroundColor: Colors.red.shade100,
),
],
),
),
],
),
);
}

Widget _buildFilterChip(String label, String? value, VoidCallback onPressed) {
return FilterChip(
label: Text(value != null ? '$label: $value' : label),
selected: value != null,
onSelected: (_) => onPressed(),
selectedColor: Theme.of(context).primaryColor.withOpacity(0.2),
);
}

Widget _buildErrorState(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red),
SizedBox(height: 16),
Text('Error loading workout splits'),
SizedBox(height: 8),
Text(error),
SizedBox(height: 16),
ElevatedButton(
onPressed: _loadData,
child: Text('Retry'),
),
],
),
);
}

Widget _buildEmptyState(
String message,
IconData icon, [
String? actionText,
VoidCallback? onAction,
]) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
message,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
if (actionText != null && onAction != null) ...[
SizedBox(height: 16),
ElevatedButton(
onPressed: onAction,
child: Text(actionText),
),
],
],
),
);
}

bool _hasActiveFilters(WorkoutSplitProvider provider) {
final filter = provider.currentFilter;
return filter.difficulty != null ||
filter.daysPerWeek != null ||
filter.isSystemDefault != null ||
(filter.search != null && filter.search!.isNotEmpty);
}

void _showSplitDetails(WorkoutSplit split) {
showDialog(
context: context,
builder: (context) => WorkoutSplitDetailsDialog(split: split),
);
}

void _showCreateSplitDialog() {
showDialog(
context: context,
builder: (context) => CreateWorkoutSplitDialog(),
);
}

void _showEditSplitDialog(WorkoutSplit split) {
showDialog(
context: context,
builder: (context) => EditWorkoutSplitDialog(split: split),
);
}

void _confirmDeleteSplit(WorkoutSplit split) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete Workout Split?'),
content: Text('This will permanently delete "${split.name}".'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
try {
await context.read<WorkoutSplitProvider>().deleteSplit(split.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Split deleted successfully')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete split')),
);
}
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: Text('Delete'),
),
],
),
);
}

void _showDifficultyFilter(WorkoutSplitProvider provider) {
showDialog(
context: context,
builder: (context) => SimpleDialog(
title: Text('Filter by Difficulty'),
children: [
...TrainingLevel.values.map((level) => SimpleDialogOption(
onPressed: () {
provider.filterByDifficulty(level);
Navigator.pop(context);
},
child: Text(level.toString().split('.').last.toLowerCase().capitalize()),
)),
SimpleDialogOption(
onPressed: () {
provider.filterByDifficulty(null);
Navigator.pop(context);
},
child: Text('All Levels'),
),
],
),
);
}

void _showDaysPerWeekFilter(WorkoutSplitProvider provider) {
showDialog(
context: context,
builder: (context) => SimpleDialog(
title: Text('Filter by Days/Week'),
children: [
...[3, 4, 5, 6, 7].map((days) => SimpleDialogOption(
onPressed: () {
provider.filterByDaysPerWeek(days);
Navigator.pop(context);
},
child: Text('$days days/week'),
)),
SimpleDialogOption(
onPressed: () {
provider.filterByDaysPerWeek(null);
Navigator.pop(context);
},
child: Text('All Frequencies'),
),
],
),
);
}

void _showTypeFilter(WorkoutSplitProvider provider) {
showDialog(
context: context,
builder: (context) => SimpleDialog(
title: Text('Filter by Type'),
children: [
SimpleDialogOption(
onPressed: () {
provider.filterByType(true);
Navigator.pop(context);
},
child: Text('System Splits'),
),
SimpleDialogOption(
onPressed: () {
provider.filterByType(false);
Navigator.pop(context);
},
child: Text('Custom Splits'),
),
SimpleDialogOption(
onPressed: () {
provider.filterByType(null);
Navigator.pop(context);
},
child: Text('All Types'),
),
],
),
);
}

@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
}

class WorkoutSplitCard extends StatelessWidget {
final WorkoutSplit split;
final VoidCallback onTap;
final bool showPopularityBadge;
final bool showEditOption;
final VoidCallback? onEdit;
final VoidCallback? onDelete;

const WorkoutSplitCard({
Key? key,
required this.split,
required this.onTap,
this.showPopularityBadge = false,
this.showEditOption = false,
this.onEdit,
this.onDelete,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
split.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 4),
Text(
split.daysPerWeekText,
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
Row(
children: [
if (showPopularityBadge) ...[
Chip(
label: Text('${split.popularityScore}'),
backgroundColor: Colors.orange.shade100,
avatar: Icon(Icons.star, size: 16),
),
SizedBox(width: 8),
],
Chip(
label: Text(split.difficultyDisplayName),
backgroundColor: _getDifficultyColor(split.difficulty),
),
if (showEditOption) ...[
SizedBox(width: 8),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') onEdit?.call();
if (value == 'delete') onDelete?.call();
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit, size: 16),
SizedBox(width: 8),
Text('Edit'),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, size: 16, color: Colors.red),
SizedBox(width: 8),
Text('Delete', style: TextStyle(color: Colors.red)),
],
),
),
],
),
],
],
),
],
),
if (split.description != null && split.description!.isNotEmpty) ...[
SizedBox(height: 8),
Text(
split.description!,
style: TextStyle(color: Colors.grey[700]),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: split.muscleGroupRotation.map((group) => Chip(
label: Text(group),
backgroundColor: Colors.blue.shade50,
labelStyle: TextStyle(fontSize: 12),
)).toList(),
),
if (split.programCount > 0) ...[
SizedBox(height: 8),
Row(
children: [
Icon(Icons.fitness_center, size: 16, color: Colors.grey),
SizedBox(width: 4),
Text(
'${split.programCount} ${split.programCount == 1 ? 'program' : 'programs'}',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
],
),
],
],
),
),
),
);
}

Color _getDifficultyColor(TrainingLevel difficulty) {
switch (difficulty) {
case TrainingLevel.beginner:
return Colors.green.shade100;
case TrainingLevel.intermediate:
return Colors.yellow.shade100;
case TrainingLevel.advanced:
return Colors.red.shade100;
}
}
}

extension StringExtension on String {
String capitalize() {
return this.isEmpty ? this : this[0].toUpperCase() + this.substring(1);
}
}

Common Use Cases

// Load the most popular workout splits
await workoutSplitProvider.loadPopularSplits();

// Display popular splits in UI
final popularSplits = workoutSplitProvider.popularSplits;

2. Filter Splits by User Level

// Filter splits for beginners
await workoutSplitProvider.filterByDifficulty(TrainingLevel.beginner);

// Filter by training frequency
await workoutSplitProvider.filterByDaysPerWeek(4);

3. Search for Specific Split Types

// Search for push/pull splits
await workoutSplitProvider.searchSplits('push pull');

// Find full body routines
await workoutSplitProvider.searchSplits('full body');

4. Create Custom Workout Split

// Create a custom 4-day upper/lower split
await workoutSplitProvider.createSplit(
name: 'Upper/Lower 4-Day',
description: 'Alternating upper and lower body workouts',
daysPerWeek: 4,
difficulty: TrainingLevel.intermediate,
muscleGroupRotation: ['Upper Body', 'Lower Body', 'Upper Body', 'Lower Body'],
);

5. Get Splits for Program Creation

// Load splits suitable for intermediate trainees
final intermediateSplits = await workoutSplitService.getWorkoutSplitsByDifficulty(
difficulty: TrainingLevel.intermediate,
limit: 10,
);

// Use in program creation workflow
showSplitSelector(intermediateSplits);

Error Handling

Permission Errors

// Handle unauthorized split operations
try {
await workoutSplitService.createWorkoutSplit(...);
} catch (e) {
if (e.toString().contains('Authentication required')) {
showLoginDialog();
} else if (e.toString().contains("don't have permission")) {
showSnackBar('Only admins can modify system splits');
}
}

Validation Errors

// Handle invalid split data
try {
await workoutSplitService.createWorkoutSplit(
name: '', // Invalid: empty name
daysPerWeek: 8, // Invalid: too many days
muscleGroupRotation: [], // Invalid: empty rotation
);
} catch (e) {
if (e.toString().contains('already exists')) {
showSnackBar('A split with this name already exists');
} else {
showSnackBar('Please check your input and try again');
}
}

Dependency Errors

// Handle splits in use by programs
try {
await workoutSplitService.deleteWorkoutSplit(splitId);
} catch (e) {
if (e.toString().contains('programs are still using')) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Cannot Delete Split'),
content: Text('This split is being used by active programs. Remove it from all programs first.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'),
),
],
),
);
}
}

Best Practices

1. Split Selection Guidance

  • Beginner: Recommend full-body or upper/lower splits
  • Intermediate: Offer push/pull/legs or body part splits
  • Advanced: Allow complex periodization splits

2. User Experience

  • Filter Persistence: Remember user filter preferences
  • Visual Hierarchy: Show system splits prominently
  • Search Optimization: Include synonyms and common terms

3. Custom Split Creation

  • Template Suggestions: Provide common muscle group patterns
  • Validation: Ensure balanced muscle group coverage
  • Preview: Show how the split looks across a week

4. Performance Optimization

  • Pagination: Implement for large split collections
  • Caching: Cache popular splits for quick access
  • Lazy Loading: Load split details on demand

5. Educational Content

  • Split Explanations: Provide rationale for each split type
  • Progression Guidance: Suggest when to advance split complexity
  • Customization Tips: Help users modify existing splits

The Workout Split API provides a comprehensive foundation for organizing and discovering optimal training structures. By combining expert-curated splits with intelligent filtering and custom creation capabilities, it enables users to find or create the perfect training organization for their goals and experience level.