Browse Source

Add completing/saving of sessions

Lee Morgan 2 weeks ago
parent
commit
7b66763aff

+ 65 - 0
app/src/androidTest/java/com/example/torus/WorkoutRepositoryTest.kt

@@ -8,6 +8,8 @@ import com.example.torus.data.WorkoutRepository
 import com.example.torus.id.UuidV7
 import com.example.torus.model.DraftExercise
 import com.example.torus.model.ExerciseType
+import com.example.torus.model.SessionExerciseInput
+import com.example.torus.model.SessionSetInput
 import java.util.UUID
 import kotlinx.coroutines.runBlocking
 import org.junit.After
@@ -109,6 +111,69 @@ class WorkoutRepositoryTest {
         assertEquals("Row", exercises[1].name)
         assertEquals(1, exercises[1].sortOrder)
     }
+
+    @Test
+    fun completeSession_writesSessionExercisesAndOrderedSets() = runBlocking {
+        val benchId = UuidV7.generateString()
+        val runId = UuidV7.generateString()
+        val workoutId = repository.saveWorkout(
+            name = "Push day",
+            exercises = listOf(
+                DraftExercise(id = benchId, type = ExerciseType.Weights, name = "Bench"),
+                DraftExercise(id = runId, type = ExerciseType.Run, name = "Easy run")
+            )
+        )
+        val startedAt = 1_700_000_000_000L
+        val finishedAt = startedAt + 3_600_000L
+
+        val sessionId = repository.completeSession(
+            workoutId = workoutId,
+            startedAt = startedAt,
+            finishedAt = finishedAt,
+            exercises = listOf(
+                SessionExerciseInput(
+                    workoutExerciseId = benchId,
+                    name = "Bench",
+                    type = ExerciseType.Weights,
+                    sets = listOf(
+                        SessionSetInput(weight = "135", reps = "8"),
+                        SessionSetInput(weight = "155", reps = "5")
+                    )
+                ),
+                SessionExerciseInput(
+                    workoutExerciseId = runId,
+                    name = "Easy run",
+                    type = ExerciseType.Run,
+                    sets = emptyList()
+                )
+            )
+        )
+
+        val session = database.sessionDao().getSession(sessionId)!!
+        assertEquals(workoutId, session.workoutId)
+        assertEquals(startedAt, session.startedAt)
+        assertEquals(finishedAt, session.finishedAt)
+        assertUuidV7(session.id)
+
+        val sessionExercises = database.sessionDao().getSessionExercises(sessionId)
+        assertEquals(2, sessionExercises.size)
+        assertEquals(benchId, sessionExercises[0].workoutExerciseId)
+        assertEquals("Bench", sessionExercises[0].name)
+        assertEquals("weights", sessionExercises[0].type)
+        assertEquals(0, sessionExercises[0].sortOrder)
+        assertEquals(runId, sessionExercises[1].workoutExerciseId)
+        assertEquals(1, sessionExercises[1].sortOrder)
+
+        val benchSets = database.sessionDao().getSessionSets(sessionExercises[0].id)
+        assertEquals(2, benchSets.size)
+        assertEquals("135", benchSets[0].weight)
+        assertEquals("8", benchSets[0].reps)
+        assertEquals(0, benchSets[0].sortOrder)
+        assertEquals("155", benchSets[1].weight)
+        assertEquals("5", benchSets[1].reps)
+        assertEquals(1, benchSets[1].sortOrder)
+        assertEquals(emptyList<Any>(), database.sessionDao().getSessionSets(sessionExercises[1].id))
+    }
 }
 
 private fun assertUuidV7(value: String) {

+ 26 - 0
app/src/main/java/com/example/torus/data/SessionDao.kt

@@ -0,0 +1,26 @@
+package com.example.torus.data
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+
+@Dao
+interface SessionDao {
+    @Insert
+    suspend fun insertSession(session: SessionEntity)
+
+    @Insert
+    suspend fun insertSessionExercises(exercises: List<SessionExerciseEntity>)
+
+    @Insert
+    suspend fun insertSessionSets(sets: List<SessionSetEntity>)
+
+    @Query("SELECT * FROM session WHERE id = :id")
+    suspend fun getSession(id: String): SessionEntity?
+
+    @Query("SELECT * FROM session_exercise WHERE session_id = :sessionId ORDER BY sort_order ASC")
+    suspend fun getSessionExercises(sessionId: String): List<SessionExerciseEntity>
+
+    @Query("SELECT * FROM session_set WHERE session_exercise_id = :sessionExerciseId ORDER BY sort_order ASC")
+    suspend fun getSessionSets(sessionExerciseId: String): List<SessionSetEntity>
+}

+ 26 - 0
app/src/main/java/com/example/torus/data/SessionEntity.kt

@@ -0,0 +1,26 @@
+package com.example.torus.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "session",
+    foreignKeys = [
+        ForeignKey(
+            entity = WorkoutEntity::class,
+            parentColumns = ["id"],
+            childColumns = ["workout_id"],
+            onDelete = ForeignKey.CASCADE
+        )
+    ],
+    indices = [Index("workout_id")]
+)
+data class SessionEntity(
+    @PrimaryKey val id: String,
+    @ColumnInfo(name = "workout_id") val workoutId: String,
+    @ColumnInfo(name = "started_at") val startedAt: Long,
+    @ColumnInfo(name = "finished_at") val finishedAt: Long,
+)

+ 28 - 0
app/src/main/java/com/example/torus/data/SessionExerciseEntity.kt

@@ -0,0 +1,28 @@
+package com.example.torus.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "session_exercise",
+    foreignKeys = [
+        ForeignKey(
+            entity = SessionEntity::class,
+            parentColumns = ["id"],
+            childColumns = ["session_id"],
+            onDelete = ForeignKey.CASCADE
+        )
+    ],
+    indices = [Index("session_id")]
+)
+data class SessionExerciseEntity(
+    @PrimaryKey val id: String,
+    @ColumnInfo(name = "session_id") val sessionId: String,
+    @ColumnInfo(name = "workout_exercise_id") val workoutExerciseId: String,
+    val name: String,
+    val type: String,
+    @ColumnInfo(name = "sort_order") val sortOrder: Int,
+)

+ 27 - 0
app/src/main/java/com/example/torus/data/SessionSetEntity.kt

@@ -0,0 +1,27 @@
+package com.example.torus.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "session_set",
+    foreignKeys = [
+        ForeignKey(
+            entity = SessionExerciseEntity::class,
+            parentColumns = ["id"],
+            childColumns = ["session_exercise_id"],
+            onDelete = ForeignKey.CASCADE
+        )
+    ],
+    indices = [Index("session_exercise_id")]
+)
+data class SessionSetEntity(
+    @PrimaryKey val id: String,
+    @ColumnInfo(name = "session_exercise_id") val sessionExerciseId: String,
+    @ColumnInfo(name = "sort_order") val sortOrder: Int,
+    val weight: String,
+    val reps: String,
+)

+ 58 - 3
app/src/main/java/com/example/torus/data/TorusDatabase.kt

@@ -8,12 +8,19 @@ import androidx.room.migration.Migration
 import androidx.sqlite.db.SupportSQLiteDatabase
 
 @Database(
-    entities = [WorkoutEntity::class, WorkoutExerciseEntity::class],
-    version = 3,
+    entities = [
+        WorkoutEntity::class,
+        WorkoutExerciseEntity::class,
+        SessionEntity::class,
+        SessionExerciseEntity::class,
+        SessionSetEntity::class
+    ],
+    version = 4,
     exportSchema = false
 )
 abstract class TorusDatabase : RoomDatabase() {
     abstract fun workoutDao(): WorkoutDao
+    abstract fun sessionDao(): SessionDao
 
     companion object {
         private val MIGRATION_2_3 = object : Migration(2, 3) {
@@ -24,12 +31,60 @@ abstract class TorusDatabase : RoomDatabase() {
             }
         }
 
+        private val MIGRATION_3_4 = object : Migration(3, 4) {
+            override fun migrate(db: SupportSQLiteDatabase) {
+                db.execSQL(
+                    """
+                    CREATE TABLE IF NOT EXISTS session (
+                        id TEXT NOT NULL PRIMARY KEY,
+                        workout_id TEXT NOT NULL,
+                        started_at INTEGER NOT NULL,
+                        finished_at INTEGER NOT NULL,
+                        FOREIGN KEY(workout_id) REFERENCES workout(id) ON DELETE CASCADE
+                    )
+                    """.trimIndent()
+                )
+                db.execSQL("CREATE INDEX IF NOT EXISTS index_session_workout_id ON session(workout_id)")
+                db.execSQL(
+                    """
+                    CREATE TABLE IF NOT EXISTS session_exercise (
+                        id TEXT NOT NULL PRIMARY KEY,
+                        session_id TEXT NOT NULL,
+                        workout_exercise_id TEXT NOT NULL,
+                        name TEXT NOT NULL,
+                        type TEXT NOT NULL,
+                        sort_order INTEGER NOT NULL,
+                        FOREIGN KEY(session_id) REFERENCES session(id) ON DELETE CASCADE
+                    )
+                    """.trimIndent()
+                )
+                db.execSQL(
+                    "CREATE INDEX IF NOT EXISTS index_session_exercise_session_id ON session_exercise(session_id)"
+                )
+                db.execSQL(
+                    """
+                    CREATE TABLE IF NOT EXISTS session_set (
+                        id TEXT NOT NULL PRIMARY KEY,
+                        session_exercise_id TEXT NOT NULL,
+                        sort_order INTEGER NOT NULL,
+                        weight TEXT NOT NULL,
+                        reps TEXT NOT NULL,
+                        FOREIGN KEY(session_exercise_id) REFERENCES session_exercise(id) ON DELETE CASCADE
+                    )
+                    """.trimIndent()
+                )
+                db.execSQL(
+                    "CREATE INDEX IF NOT EXISTS index_session_set_session_exercise_id ON session_set(session_exercise_id)"
+                )
+            }
+        }
+
         fun create(context: Context): TorusDatabase {
             return Room.databaseBuilder(
                 context.applicationContext,
                 TorusDatabase::class.java,
                 "torus.db"
-            ).addMigrations(MIGRATION_2_3).build()
+            ).addMigrations(MIGRATION_2_3, MIGRATION_3_4).build()
         }
     }
 }

+ 49 - 0
app/src/main/java/com/example/torus/data/WorkoutRepository.kt

@@ -5,6 +5,7 @@ import com.example.torus.id.UuidV7
 import com.example.torus.model.DraftExercise
 import com.example.torus.model.ExerciseType
 import com.example.torus.model.SavedExercise
+import com.example.torus.model.SessionExerciseInput
 import com.example.torus.model.WorkoutDetail
 import com.example.torus.model.WorkoutSummary
 import kotlinx.coroutines.flow.Flow
@@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.map
 
 class WorkoutRepository(private val database: TorusDatabase) {
     private val workoutDao = database.workoutDao()
+    private val sessionDao = database.sessionDao()
 
     fun observeWorkouts(): Flow<List<WorkoutSummary>> {
         return workoutDao.observeWorkouts().map { workouts ->
@@ -93,4 +95,51 @@ class WorkoutRepository(private val database: TorusDatabase) {
             }
         }
     }
+
+    suspend fun completeSession(
+        workoutId: String,
+        startedAt: Long,
+        finishedAt: Long,
+        exercises: List<SessionExerciseInput>
+    ): String {
+        return database.withTransaction {
+            val sessionId = UuidV7.generateString()
+            sessionDao.insertSession(
+                SessionEntity(
+                    id = sessionId,
+                    workoutId = workoutId,
+                    startedAt = startedAt,
+                    finishedAt = finishedAt
+                )
+            )
+            val sessionExercises = exercises.mapIndexed { index, exercise ->
+                SessionExerciseEntity(
+                    id = UuidV7.generateString(),
+                    sessionId = sessionId,
+                    workoutExerciseId = exercise.workoutExerciseId,
+                    name = exercise.name,
+                    type = exercise.type.storageValue,
+                    sortOrder = index
+                )
+            }
+            if (sessionExercises.isNotEmpty()) {
+                sessionDao.insertSessionExercises(sessionExercises)
+            }
+            val sessionSets = sessionExercises.flatMapIndexed { exerciseIndex, sessionExercise ->
+                exercises[exerciseIndex].sets.mapIndexed { setIndex, set ->
+                    SessionSetEntity(
+                        id = UuidV7.generateString(),
+                        sessionExerciseId = sessionExercise.id,
+                        sortOrder = setIndex,
+                        weight = set.weight,
+                        reps = set.reps
+                    )
+                }
+            }
+            if (sessionSets.isNotEmpty()) {
+                sessionDao.insertSessionSets(sessionSets)
+            }
+            sessionId
+        }
+    }
 }

+ 13 - 0
app/src/main/java/com/example/torus/model/SessionInput.kt

@@ -0,0 +1,13 @@
+package com.example.torus.model
+
+data class SessionExerciseInput(
+    val workoutExerciseId: String,
+    val name: String,
+    val type: ExerciseType,
+    val sets: List<SessionSetInput>,
+)
+
+data class SessionSetInput(
+    val weight: String,
+    val reps: String,
+)

+ 32 - 0
app/src/main/java/com/example/torus/ui/ConfirmationBanner.kt

@@ -0,0 +1,32 @@
+package com.example.torus.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.example.torus.ui.theme.Ok
+
+@Composable
+fun ConfirmationBanner(
+    message: String,
+    modifier: Modifier = Modifier
+) {
+    val shape = RoundedCornerShape(14.dp)
+    Text(
+        text = message,
+        modifier = modifier
+            .fillMaxWidth()
+            .padding(16.dp)
+            .background(MaterialTheme.colorScheme.surfaceVariant, shape)
+            .border(1.dp, Ok, shape)
+            .padding(horizontal = 16.dp, vertical = 12.dp),
+        color = MaterialTheme.colorScheme.onBackground,
+        style = MaterialTheme.typography.bodyLarge
+    )
+}

+ 113 - 59
app/src/main/java/com/example/torus/ui/StartWorkoutScreen.kt

@@ -39,6 +39,8 @@ import com.example.torus.data.WorkoutRepository
 import com.example.torus.id.UuidV7
 import com.example.torus.model.ExerciseType
 import com.example.torus.model.SavedExercise
+import com.example.torus.model.SessionExerciseInput
+import com.example.torus.model.SessionSetInput
 import com.example.torus.model.WorkoutDetail
 import com.example.torus.ui.theme.Sand
 import com.example.torus.ui.theme.TorusTheme
@@ -53,18 +55,27 @@ private data class WeightSetDraft(
 fun StartWorkoutScreen(
     workoutId: String,
     workoutRepository: WorkoutRepository,
-    modifier: Modifier = Modifier
+    modifier: Modifier = Modifier,
+    onCompleteWorkout: (startedAt: Long, exercises: List<SessionExerciseInput>) -> Unit = { _, _ -> }
 ) {
     var detail by remember(workoutId) { mutableStateOf<WorkoutDetail?>(null) }
     var exerciseIndex by remember(workoutId) { mutableIntStateOf(0) }
+    var isCompleting by remember { mutableStateOf(false) }
+    val startedAt = remember(workoutId) { System.currentTimeMillis() }
     val setsByExercise = remember(workoutId) {
         mutableStateMapOf<String, SnapshotStateList<WeightSetDraft>>()
     }
 
     LaunchedEffect(workoutId) {
-        detail = workoutRepository.getWorkoutDetail(workoutId)
+        val workout = workoutRepository.getWorkoutDetail(workoutId)
+        detail = workout
         exerciseIndex = 0
         setsByExercise.clear()
+        workout?.exercises?.forEach { exercise ->
+            if (exercise.type == ExerciseType.Weights) {
+                setsByExercise[exercise.id] = defaultWeightSets()
+            }
+        }
     }
 
     val workout = detail ?: return
@@ -72,8 +83,15 @@ fun StartWorkoutScreen(
         workout = workout,
         exerciseIndex = exerciseIndex,
         setsByExercise = setsByExercise,
+        isCompleting = isCompleting,
         modifier = modifier,
-        onNextExercise = { exerciseIndex += 1 }
+        onNextExercise = { exerciseIndex += 1 },
+        onCompleteWorkout = {
+            if (!isCompleting) {
+                isCompleting = true
+                onCompleteWorkout(startedAt, workout.toSessionInput(setsByExercise))
+            }
+        }
     )
 }
 
@@ -82,24 +100,12 @@ private fun StartWorkoutContent(
     workout: WorkoutDetail,
     exerciseIndex: Int,
     setsByExercise: MutableMap<String, SnapshotStateList<WeightSetDraft>>,
+    isCompleting: Boolean = false,
     modifier: Modifier = Modifier,
-    onNextExercise: () -> Unit = {}
+    onNextExercise: () -> Unit = {},
+    onCompleteWorkout: () -> Unit = {}
 ) {
     val exercises = workout.exercises
-    if (exercises.isEmpty()) {
-        Text(
-            text = stringResource(R.string.no_exercises),
-            modifier = modifier.padding(16.dp),
-            color = MaterialTheme.colorScheme.onBackground,
-            style = MaterialTheme.typography.bodyLarge
-        )
-        return
-    }
-
-    val currentIndex = exerciseIndex.coerceIn(0, exercises.lastIndex)
-    val exercise = exercises[currentIndex]
-    val isLastExercise = currentIndex == exercises.lastIndex
-
     Column(modifier = modifier.fillMaxSize()) {
         Column(
             modifier = Modifier
@@ -114,57 +120,85 @@ private fun StartWorkoutContent(
                 color = Sand,
                 style = MaterialTheme.typography.headlineMedium
             )
-            Text(
-                text = stringResource(
-                    R.string.exercise_progress,
-                    currentIndex + 1,
-                    exercises.size
-                ),
-                color = MaterialTheme.colorScheme.onSurfaceVariant,
-                style = MaterialTheme.typography.titleSmall
-            )
-            Text(
-                text = exercise.name.ifBlank { stringResource(R.string.untitled_exercise) },
-                color = MaterialTheme.colorScheme.onBackground,
-                style = MaterialTheme.typography.headlineSmall
-            )
-            Text(
-                text = stringResource(exercise.type.labelRes),
-                color = MaterialTheme.colorScheme.onSurfaceVariant,
-                style = MaterialTheme.typography.labelLarge
-            )
-            if (exercise.type == ExerciseType.Weights) {
-                val sets = setsByExercise.getOrPut(exercise.id) { defaultWeightSets() }
-                WeightSetsEditor(
-                    sets = sets,
-                    onAddSet = { sets += WeightSetDraft(id = UuidV7.generateString()) },
-                    onRemoveSet = { setId ->
-                        if (sets.size > 1) {
-                            sets.removeAll { it.id == setId }
-                        }
-                    },
-                    onWeightChange = { setId, weight ->
-                        updateSet(sets, setId) { it.copy(weight = weight) }
-                    },
-                    onRepsChange = { setId, reps ->
-                        updateSet(sets, setId) { it.copy(reps = reps) }
-                    }
+            if (exercises.isEmpty()) {
+                Text(
+                    text = stringResource(R.string.no_exercises),
+                    color = MaterialTheme.colorScheme.onBackground,
+                    style = MaterialTheme.typography.bodyLarge
                 )
+            } else {
+                val currentIndex = exerciseIndex.coerceIn(0, exercises.lastIndex)
+                val exercise = exercises[currentIndex]
+                Text(
+                    text = stringResource(
+                        R.string.exercise_progress,
+                        currentIndex + 1,
+                        exercises.size
+                    ),
+                    color = MaterialTheme.colorScheme.onSurfaceVariant,
+                    style = MaterialTheme.typography.titleSmall
+                )
+                Text(
+                    text = exercise.name.ifBlank { stringResource(R.string.untitled_exercise) },
+                    color = MaterialTheme.colorScheme.onBackground,
+                    style = MaterialTheme.typography.headlineSmall
+                )
+                Text(
+                    text = stringResource(exercise.type.labelRes),
+                    color = MaterialTheme.colorScheme.onSurfaceVariant,
+                    style = MaterialTheme.typography.labelLarge
+                )
+                if (exercise.type == ExerciseType.Weights) {
+                    val sets = setsByExercise.getOrPut(exercise.id) { defaultWeightSets() }
+                    WeightSetsEditor(
+                        sets = sets,
+                        onAddSet = { sets += WeightSetDraft(id = UuidV7.generateString()) },
+                        onRemoveSet = { setId ->
+                            if (sets.size > 1) {
+                                sets.removeAll { it.id == setId }
+                            }
+                        },
+                        onWeightChange = { setId, weight ->
+                            updateSet(sets, setId) { it.copy(weight = weight) }
+                        },
+                        onRepsChange = { setId, reps ->
+                            updateSet(sets, setId) { it.copy(reps = reps) }
+                        }
+                    )
+                }
             }
         }
-        if (!isLastExercise) {
+        val isLastExercise = exercises.isEmpty() ||
+            exerciseIndex.coerceIn(0, exercises.lastIndex) == exercises.lastIndex
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp, vertical = 12.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            if (!isLastExercise) {
+                OutlinedButton(
+                    onClick = onNextExercise,
+                    modifier = Modifier.fillMaxWidth(),
+                    shape = RoundedCornerShape(50),
+                    colors = ButtonDefaults.outlinedButtonColors(
+                        contentColor = MaterialTheme.colorScheme.onBackground
+                    )
+                ) {
+                    Text(text = stringResource(R.string.next_exercise))
+                }
+            }
             Button(
-                onClick = onNextExercise,
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .padding(horizontal = 16.dp, vertical = 12.dp),
+                onClick = onCompleteWorkout,
+                enabled = !isCompleting,
+                modifier = Modifier.fillMaxWidth(),
                 shape = RoundedCornerShape(50),
                 colors = ButtonDefaults.buttonColors(
                     containerColor = MaterialTheme.colorScheme.primary,
                     contentColor = MaterialTheme.colorScheme.onPrimary
                 )
             ) {
-                Text(text = stringResource(R.string.next_exercise))
+                Text(text = stringResource(R.string.complete_workout))
             }
         }
     }
@@ -276,6 +310,26 @@ private fun updateSet(
     }
 }
 
+private fun WorkoutDetail.toSessionInput(
+    setsByExercise: Map<String, List<WeightSetDraft>>
+): List<SessionExerciseInput> {
+    return exercises.map { exercise ->
+        val sets = if (exercise.type == ExerciseType.Weights) {
+            (setsByExercise[exercise.id] ?: defaultWeightSets()).map { set ->
+                SessionSetInput(weight = set.weight, reps = set.reps)
+            }
+        } else {
+            emptyList()
+        }
+        SessionExerciseInput(
+            workoutExerciseId = exercise.id,
+            name = exercise.name,
+            type = exercise.type,
+            sets = sets
+        )
+    }
+}
+
 private fun filterDecimal(value: String): String {
     val filtered = value.filter { it.isDigit() || it == '.' }
     val firstDot = filtered.indexOf('.')

+ 33 - 6
app/src/main/java/com/example/torus/ui/TorusApp.kt

@@ -4,14 +4,19 @@ import androidx.activity.compose.BackHandler
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.example.torus.R
 import com.example.torus.data.WorkoutRepository
 import kotlinx.coroutines.launch
 
@@ -43,6 +48,8 @@ fun TorusApp(workoutRepository: WorkoutRepository) {
     var navigationStack by rememberSaveable { mutableStateOf(listOf(Routes.HOME)) }
     val currentRoute = navigationStack.last()
     val coroutineScope = rememberCoroutineScope()
+    val snackbarHostState = remember { SnackbarHostState() }
+    val workoutCompletedMessage = stringResource(R.string.workout_completed)
     val workouts by workoutRepository.observeWorkouts().collectAsState(initial = emptyList())
 
     fun navigateTo(route: String) {
@@ -65,7 +72,12 @@ fun TorusApp(workoutRepository: WorkoutRepository) {
 
     Scaffold(
         modifier = Modifier.fillMaxSize(),
-        topBar = { AppHeader(onClick = { goHome() }) }
+        topBar = { AppHeader(onClick = { goHome() }) },
+        snackbarHost = {
+            SnackbarHost(hostState = snackbarHostState) { data ->
+                ConfirmationBanner(message = data.visuals.message)
+            }
+        }
     ) { innerPadding ->
         val contentModifier = Modifier.padding(innerPadding)
         when {
@@ -95,11 +107,26 @@ fun TorusApp(workoutRepository: WorkoutRepository) {
                     onHome = { goHome() }
                 )
             }
-            Routes.isStart(currentRoute) -> StartWorkoutScreen(
-                workoutId = Routes.startId(currentRoute),
-                workoutRepository = workoutRepository,
-                modifier = contentModifier
-            )
+            Routes.isStart(currentRoute) -> {
+                val workoutId = Routes.startId(currentRoute)
+                StartWorkoutScreen(
+                    workoutId = workoutId,
+                    workoutRepository = workoutRepository,
+                    modifier = contentModifier,
+                    onCompleteWorkout = { startedAt, exercises ->
+                        coroutineScope.launch {
+                            workoutRepository.completeSession(
+                                workoutId = workoutId,
+                                startedAt = startedAt,
+                                finishedAt = System.currentTimeMillis(),
+                                exercises = exercises
+                            )
+                            popBack()
+                            snackbarHostState.showSnackbar(workoutCompletedMessage)
+                        }
+                    }
+                )
+            }
             Routes.isEdit(currentRoute) -> {
                 val workoutId = Routes.editId(currentRoute)
                 EditWorkoutScreen(

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -24,5 +24,7 @@
     <string name="add_set">Add set</string>
     <string name="remove_set">Remove set</string>
     <string name="next_exercise">Next exercise</string>
+    <string name="complete_workout">Complete workout</string>
+    <string name="workout_completed">Workout completed and saved</string>
     <string name="no_exercises">This workout has no exercises.</string>
 </resources>