KinesteX

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 elementFieldNotes
Titlecontent_title
Thumbnail imagecontent_image_url
Calories ("125 cal")calories_burnedActual calories burned (double — round it)
Duration ("12 min")actual_duration_secondsActual seconds spent — format it ("12 sec", "15 min", "1 hr 5 min")
Difficulty badgecontent_difficulty
Progress bar + "% complete"completion_percentage0–100, can be fractional (e.g. 0.67) — authoritative, round for display
Exercise counttotal_exercisesTotal planned exercises (includes untouched ones)
Tap → open sessionid → 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 countstotal_exercises includes 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, approximate round(completionPercentage / 100 * totalExercises) and treat it as visual only.
  • Pagination: the response includes pagination.has_more; request the next page to 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 } }