# 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:

```
Workout 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_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](/docs/trainer-api/trainer-api-workout-sessions).

**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 (iOS)_
```swift
struct SessionsResponse: Decodable {
    let sessions: [WorkoutSession]
}

struct WorkoutSession: Decodable {
    let id: Int
    let contentTitle: String
    let contentImageUrl: String
    let contentDifficulty: String
    let caloriesBurned: Double            // actual calories the user burned
    let actualDurationSeconds: Int        // actual time spent exercising
    let completionPercentage: Double      // 0–100, authoritative
    let totalExercises: Int

    enum CodingKeys: String, CodingKey {
        case id
        case contentTitle          = "content_title"
        case contentImageUrl       = "content_image_url"
        case contentDifficulty     = "content_difficulty"
        case caloriesBurned        = "calories_burned"
        case actualDurationSeconds = "actual_duration_seconds"
        case completionPercentage  = "completion_percentage"
        case totalExercises        = "total_exercises"
    }
}

func fetchSessions(apiKey: String,
                   userId: String,
                   page: Int = 1,
                   limit: Int = 20) async throws -> [WorkoutSession] {
    var components = URLComponents(string: "https://data.kinestex.com/api/workout-sessions")!
    components.queryItems = [
        URLQueryItem(name: "page",  value: "\(page)"),
        URLQueryItem(name: "limit", value: "\(limit)"),
        URLQueryItem(name: "sort",  value: "started_at_desc")
    ]

    var request = URLRequest(url: components.url!)
    request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
    request.setValue(userId, forHTTPHeaderField: "x-user-id")

    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
        throw URLError(.badServerResponse)
    }
    return try JSONDecoder().decode(SessionsResponse.self, from: data).sessions
}
```

_Kotlin (Android)_
```kotlin
// Uses OkHttp + Gson. Run the request off the main thread.
data class SessionsResponse(val sessions: List<WorkoutSession>)

data class WorkoutSession(
    val id: Int,
    @SerializedName("content_title")           val contentTitle: String,
    @SerializedName("content_image_url")       val contentImageUrl: String,
    @SerializedName("content_difficulty")      val contentDifficulty: String,
    @SerializedName("calories_burned")         val caloriesBurned: Double,       // actual calories burned
    @SerializedName("actual_duration_seconds") val actualDurationSeconds: Int,   // actual time spent
    @SerializedName("completion_percentage")   val completionPercentage: Double, // 0–100
    @SerializedName("total_exercises")         val totalExercises: Int
)

suspend fun fetchSessions(
    apiKey: String,
    userId: String,
    page: Int = 1,
    limit: Int = 20
): List<WorkoutSession> = withContext(Dispatchers.IO) {
    val url = "https://data.kinestex.com/api/workout-sessions" +
              "?page=$page&limit=$limit&sort=started_at_desc"
    val request = Request.Builder()
        .url(url)
        .addHeader("x-api-key", apiKey)
        .addHeader("x-user-id", userId)
        .build()

    OkHttpClient().newCall(request).execute().use { response ->
        if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
        Gson().fromJson(response.body!!.string(), SessionsResponse::class.java).sessions
    }
}
```

**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 (iOS)_
```swift
struct CompletedWorkoutCardView: View {
    let session: WorkoutSession
    var onTap: () -> Void

    private var durationText: String {
        let s = session.actualDurationSeconds
        switch s {
        case ..<60:   return "\(s) sec"
        case ..<3600: return "\(s / 60) min"
        default:
            let h = s / 3600, m = (s % 3600) / 60
            return m == 0 ? "\(h) hr" : "\(h) hr \(m) min"
        }
    }

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: 16) {
                AsyncImage(url: URL(string: session.contentImageUrl)) {
                    $0.resizable().aspectRatio(contentMode: .fill)
                } placeholder: { Color.gray.opacity(0.15) }
                .frame(width: 96, height: 96)
                .clipShape(RoundedRectangle(cornerRadius: 16))

                VStack(alignment: .leading, spacing: 10) {
                    Text(session.contentTitle).font(.title3).bold()
                    HStack(spacing: 16) {
                        Label("\(Int(session.caloriesBurned.rounded())) cal", systemImage: "flame.fill")
                        Label(durationText, systemImage: "clock.fill")
                        Label(session.contentDifficulty, systemImage: "chart.bar.fill")
                    }
                    .font(.subheadline)
                    // Progress is driven by completion_percentage (authoritative)
                    ProgressView(value: session.completionPercentage, total: 100)
                    HStack {
                        Text("\(Int(session.completionPercentage.rounded()))% complete")
                        Spacer()
                        Text("\(session.totalExercises) exercises")
                    }
                    .font(.caption).foregroundColor(.secondary)
                }
            }
            .padding()
            .background(Color(.secondarySystemBackground))
            .clipShape(RoundedRectangle(cornerRadius: 20))
        }
        .buttonStyle(.plain)
    }
}
```

_Kotlin (Android)_
```kotlin
// Jetpack Compose; uses Coil (AsyncImage) for the thumbnail.
@Composable
fun CompletedWorkoutCard(session: WorkoutSession, onClick: () -> Unit) {
    val durationText = when {
        session.actualDurationSeconds < 60   -> "${session.actualDurationSeconds} sec"
        session.actualDurationSeconds < 3600 -> "${session.actualDurationSeconds / 60} min"
        else -> {
            val h = session.actualDurationSeconds / 3600
            val m = (session.actualDurationSeconds % 3600) / 60
            if (m == 0) "$h hr" else "$h hr $m min"
        }
    }

    Surface(onClick = onClick, shape = RoundedCornerShape(20.dp), modifier = Modifier.fillMaxWidth()) {
        Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
            AsyncImage(
                model = session.contentImageUrl,
                contentDescription = session.contentTitle,
                contentScale = ContentScale.Crop,
                modifier = Modifier.size(96.dp).clip(RoundedCornerShape(16.dp))
            )
            Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
                Text(session.contentTitle, style = MaterialTheme.typography.titleLarge)
                Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
                    Text("🔥 ${session.caloriesBurned.roundToInt()} cal")
                    Text("⏱ $durationText")
                    Text("📊 ${session.contentDifficulty}")
                }
                // Progress is driven by completion_percentage (authoritative)
                LinearProgressIndicator(
                    progress = { (session.completionPercentage / 100f).toFloat() },
                    modifier = Modifier.fillMaxWidth()
                )
                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                    Text("${session.completionPercentage.roundToInt()}% complete")
                    Text("${session.totalExercises} exercises")
                }
            }
        }
    }
}
```

**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 (iOS)_
```swift
struct CompletedWorkoutsView: View {
    @State private var sessions: [WorkoutSession] = []
    @State private var openSessionId: Int? = nil
    @State private var isLoading = true

    let kinestex: KinesteXAIKit
    let user: UserDetails
    let apiKey: String
    let userId: String

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                ForEach(sessions, id: \.id) { session in
                    CompletedWorkoutCardView(session: session) {
                        openSessionId = session.id   // ← open the session on tap
                    }
                }
            }
            .padding()
        }
        .task {
            sessions = (try? await fetchSessions(apiKey: apiKey, userId: userId)) ?? []
        }
        .fullScreenCover(item: Binding(
            get: { openSessionId.map { SessionID(value: $0) } },
            set: { openSessionId = $0?.value }
        )) { wrapped in
            kinestex.createCustomComponentView(
                route: "session/\(wrapped.value)",
                user: user,
                style: IStyle(style: "light", themeName: "YOUR_THEME"),
                isLoading: $isLoading,
                onMessageReceived: { message in
                    switch message {
                    case .exit_kinestex(_):         openSessionId = nil   // dismiss
                    case .error_occurred(let data): print("Error: \(data)")
                    default: break
                    }
                }
            )
        }
    }
}

// Lets an Int drive .fullScreenCover(item:)
struct SessionID: Identifiable { let value: Int; var id: Int { value } }
```

_Kotlin (Android)_
```kotlin
@Composable
fun CompletedWorkoutsScreen(apiKey: String, userId: String) {
    var sessions by remember { mutableStateOf<List<WorkoutSession>>(emptyList()) }
    var openSessionId by remember { mutableStateOf<Int?>(null) }

    LaunchedEffect(Unit) {
        sessions = runCatching { fetchSessions(apiKey, userId) }.getOrDefault(emptyList())
    }

    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        items(sessions) { session ->
            CompletedWorkoutCard(session) {
                openSessionId = session.id       // ← open the session on tap
            }
        }
    }

    // Render the KinesteX session view when a card is tapped. Build it exactly
    // like your other KinesteX views — the only thing that changes is the
    // route string: "session/$id". Handle exit_kinestex to dismiss.
    openSessionId?.let { id ->
        KinesteXSessionView(
            route = "session/$id",
            onExit = { openSessionId = null }
        )
    }
}
```

---
Source: https://www.kinestex.com/docs/guides/guide-completed-workouts · Index: https://www.kinestex.com/llms.txt
