Completed Workouts List
Build a "completed workouts" list — cards that show each workout a user has finished, with completion progress, and that open the full session summary when tapped.
KinesteX keeps every session server-side, tied to the userId you initialized the SDK with. One API call gets everything the card needs; tapping the card opens a second view:
typescript
1Workout Sessions API → card model → card UI → on tap: createCustomComponentView(route: "session/{id}")Field mapping — where each part of the card comes from:
| Card element | Field | Notes |
| Title | content_title | |
| Thumbnail image | content_image_url | |
| Calories ("125 cal") | calories_burned | Actual calories burned (double — round it) |
| Duration ("12 min") | actual_duration_seconds | Actual seconds spent — format it ("12 sec", "15 min", "1 hr 5 min") |
| Difficulty badge | content_difficulty | |
| Progress bar + "% complete" | completion_percentage | 0–100, can be fractional (e.g. 0.67) — authoritative, round for display |
| Exercise count | total_exercises | Total planned exercises (includes untouched ones) |
| Tap → open session | id → route session/{id} | Via createCustomComponentView |
Prerequisites:
1. SDK initialized with your apiKey, companyName, and userId.
2. Sessions exist — a session is only persisted if the workout was launched with "shouldSendStats": true.
3. The x-user-id you send to the Sessions API must equal the SDK's userId — sessions are tied to it.
Design notes:
- The card shows what the user actually did — a 15-minute workout abandoned after 12 seconds shows "12 sec" and ~1 cal, which is correct for a history view.
- Don't derive completion from exercise counts —
total_exercisesincludes untouched exercises, so a barely-started workout would otherwise look finished. The API doesn't expose a "completed exercises" integer; if you want a "4 of 7" look, approximateround(completionPercentage / 100 * totalExercises)and treat it as visual only. - Pagination: the response includes
pagination.has_more; request the nextpageto load more (default limit 20, max 50).
Endpoint reference: Workout Sessions API.
1. Fetch the completed sessions
The session history is a plain REST endpoint (no SDK wrapper). Run requests off the main thread on Android.
Swift example:
1struct SessionsResponse: Decodable {
2 let sessions: [WorkoutSession]
3}
4
5struct WorkoutSession: Decodable {
6 let id: Int
7 let contentTitle: String
8 let contentImageUrl: String
9 let contentDifficulty: String
10 let caloriesBurned: Double // actual calories the user burned
11 let actualDurationSeconds: Int // actual time spent exercising
12 let completionPercentage: Double // 0–100, authoritative
13 let totalExercises: Int
14
15 enum CodingKeys: String, CodingKey {
16 case id
17 case contentTitle = "content_title"
18 case contentImageUrl = "content_image_url"
19 case contentDifficulty = "content_difficulty"
20 case caloriesBurned = "calories_burned"
21 case actualDurationSeconds = "actual_duration_seconds"
22 case completionPercentage = "completion_percentage"
23 case totalExercises = "total_exercises"
24 }
25}
26
27func fetchSessions(apiKey: String,
28 userId: String,
29 page: Int = 1,
30 limit: Int = 20) async throws -> [WorkoutSession] {
31 var components = URLComponents(string: "https://data.kinestex.com/api/workout-sessions")!
32 components.queryItems = [
33 URLQueryItem(name: "page", value: "\(page)"),
34 URLQueryItem(name: "limit", value: "\(limit)"),
35 URLQueryItem(name: "sort", value: "started_at_desc")
36 ]
37
38 var request = URLRequest(url: components.url!)
39 request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
40 request.setValue(userId, forHTTPHeaderField: "x-user-id")
41
42 let (data, response) = try await URLSession.shared.data(for: request)
43 guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
44 throw URLError(.badServerResponse)
45 }
46 return try JSONDecoder().decode(SessionsResponse.self, from: data).sessions
47}2. Render the card
Each session maps 1:1 into a card — no extra network calls, no merging. Duration is in seconds; calories can be fractional.
Swift example:
1struct CompletedWorkoutCardView: View {
2 let session: WorkoutSession
3 var onTap: () -> Void
4
5 private var durationText: String {
6 let s = session.actualDurationSeconds
7 switch s {
8 case ..<60: return "\(s) sec"
9 case ..<3600: return "\(s / 60) min"
10 default:
11 let h = s / 3600, m = (s % 3600) / 60
12 return m == 0 ? "\(h) hr" : "\(h) hr \(m) min"
13 }
14 }
15
16 var body: some View {
17 Button(action: onTap) {
18 HStack(spacing: 16) {
19 AsyncImage(url: URL(string: session.contentImageUrl)) {
20 $0.resizable().aspectRatio(contentMode: .fill)
21 } placeholder: { Color.gray.opacity(0.15) }
22 .frame(width: 96, height: 96)
23 .clipShape(RoundedRectangle(cornerRadius: 16))
24
25 VStack(alignment: .leading, spacing: 10) {
26 Text(session.contentTitle).font(.title3).bold()
27 HStack(spacing: 16) {
28 Label("\(Int(session.caloriesBurned.rounded())) cal", systemImage: "flame.fill")
29 Label(durationText, systemImage: "clock.fill")
30 Label(session.contentDifficulty, systemImage: "chart.bar.fill")
31 }
32 .font(.subheadline)
33 // Progress is driven by completion_percentage (authoritative)
34 ProgressView(value: session.completionPercentage, total: 100)
35 HStack {
36 Text("\(Int(session.completionPercentage.rounded()))% complete")
37 Spacer()
38 Text("\(session.totalExercises) exercises")
39 }
40 .font(.caption).foregroundColor(.secondary)
41 }
42 }
43 .padding()
44 .background(Color(.secondarySystemBackground))
45 .clipShape(RoundedRectangle(cornerRadius: 20))
46 }
47 .buttonStyle(.plain)
48 }
49}3. Open the session when the card is tapped
Tapping a card opens its full summary screen via createCustomComponentView using the "session/{id}" route. Only the session id is needed — KinesteX holds the full record server-side. Handle exit_kinestex to dismiss.
Swift example:
1struct CompletedWorkoutsView: View {
2 @State private var sessions: [WorkoutSession] = []
3 @State private var openSessionId: Int? = nil
4 @State private var isLoading = true
5
6 let kinestex: KinesteXAIKit
7 let user: UserDetails
8 let apiKey: String
9 let userId: String
10
11 var body: some View {
12 ScrollView {
13 VStack(spacing: 16) {
14 ForEach(sessions, id: \.id) { session in
15 CompletedWorkoutCardView(session: session) {
16 openSessionId = session.id // ← open the session on tap
17 }
18 }
19 }
20 .padding()
21 }
22 .task {
23 sessions = (try? await fetchSessions(apiKey: apiKey, userId: userId)) ?? []
24 }
25 .fullScreenCover(item: Binding(
26 get: { openSessionId.map { SessionID(value: $0) } },
27 set: { openSessionId = $0?.value }
28 )) { wrapped in
29 kinestex.createCustomComponentView(
30 route: "session/\(wrapped.value)",
31 user: user,
32 style: IStyle(style: "light", themeName: "YOUR_THEME"),
33 isLoading: $isLoading,
34 onMessageReceived: { message in
35 switch message {
36 case .exit_kinestex(_): openSessionId = nil // dismiss
37 case .error_occurred(let data): print("Error: \(data)")
38 default: break
39 }
40 }
41 )
42 }
43 }
44}
45
46// Lets an Int drive .fullScreenCover(item:)
47struct SessionID: Identifiable { let value: Int; var id: Int { value } }