Procházet zdrojové kódy

Add distance information to database.

Lee Morgan před 2 týdny
rodič
revize
34cbac8d41

+ 21 - 12
app/src/androidTest/java/com/example/torus/HomeScreenTest.kt

@@ -21,12 +21,12 @@ class HomeScreenTest {
     fun homeScreen_displaysLogoAndTitle() {
         composeTestRule.onNodeWithText("Torus").assertIsDisplayed()
         composeTestRule.onNodeWithContentDescription("Torus logo").assertIsDisplayed()
-        composeTestRule.onNodeWithText("Add exercise").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Add workout").assertIsDisplayed()
     }
 
     @Test
-    fun addExercise_opensCreateWorkoutPage() {
-        composeTestRule.onNodeWithText("Add exercise").performClick()
+    fun addWorkout_opensCreateWorkoutPage() {
+        composeTestRule.onNodeWithText("Add workout").performClick()
         composeTestRule.onNodeWithText("Create Workout").assertIsDisplayed()
         composeTestRule.onNodeWithText("Save Workout").assertIsDisplayed()
         composeTestRule.onNodeWithText("Torus").assertIsDisplayed()
@@ -35,22 +35,22 @@ class HomeScreenTest {
 
     @Test
     fun headerClick_returnsToHome() {
-        composeTestRule.onNodeWithText("Add exercise").performClick()
+        composeTestRule.onNodeWithText("Add workout").performClick()
         composeTestRule.onNodeWithText("Create Workout").assertIsDisplayed()
         composeTestRule.onNodeWithText("Torus").performClick()
-        composeTestRule.onNodeWithText("Add exercise").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Add workout").assertIsDisplayed()
         composeTestRule.onNodeWithText("Create Workout").assertDoesNotExist()
     }
 
     @Test
     fun createWorkout_showsNameFieldAndExerciseTypes() {
-        composeTestRule.onNodeWithText("Add exercise").performClick()
+        composeTestRule.onNodeWithText("Add workout").performClick()
         composeTestRule.onNodeWithText("Workout name").assertIsDisplayed()
         composeTestRule.onNodeWithText("Weights").assertDoesNotExist()
         composeTestRule.onNodeWithText("Add exercise").performClick()
         composeTestRule.onNodeWithText("What type of exercise?").assertIsDisplayed()
         composeTestRule.onNodeWithText("Weights").assertIsDisplayed()
-        composeTestRule.onNodeWithText("Run").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Distance").assertIsDisplayed()
         composeTestRule.onNodeWithText("Weights").performClick()
         composeTestRule.onNodeWithText("Create Workout").assertIsDisplayed()
         composeTestRule.onNodeWithText("Exercise name").assertIsDisplayed()
@@ -61,7 +61,7 @@ class HomeScreenTest {
 
     @Test
     fun createWorkout_canAddAnotherWeightsExercise() {
-        composeTestRule.onNodeWithText("Add exercise").performClick()
+        composeTestRule.onNodeWithText("Add workout").performClick()
         composeTestRule.onNodeWithText("Add exercise").performClick()
         composeTestRule.onNodeWithText("Weights").performClick()
         composeTestRule.onNodeWithText("Add exercise").performClick()
@@ -73,14 +73,23 @@ class HomeScreenTest {
     }
 
     @Test
-    fun createWorkout_addsRunExercise() {
+    fun createWorkout_addsDistanceExercise() {
+        composeTestRule.onNodeWithText("Add workout").performClick()
         composeTestRule.onNodeWithText("Add exercise").performClick()
-        composeTestRule.onNodeWithText("Add exercise").performClick()
-        composeTestRule.onNodeWithText("Run").performClick()
-        composeTestRule.onNodeWithText("Run").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Distance").performClick()
+        composeTestRule.onNodeWithText("Distance").assertIsDisplayed()
         composeTestRule.onNodeWithText("Exercise name").assertIsDisplayed()
         composeTestRule.onNodeWithText("1").assertIsDisplayed()
         composeTestRule.onNodeWithText("Add exercise").assertIsDisplayed()
         composeTestRule.onNodeWithText("What type of exercise?").assertDoesNotExist()
     }
+
+    @Test
+    fun createWorkout_cancelReturnsHomeWithoutSaving() {
+        composeTestRule.onNodeWithText("Add workout").performClick()
+        composeTestRule.onNodeWithText("Create Workout").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Cancel").performClick()
+        composeTestRule.onNodeWithText("Add workout").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Create Workout").assertDoesNotExist()
+    }
 }

+ 12 - 7
app/src/androidTest/java/com/example/torus/WorkoutRepositoryTest.kt

@@ -51,7 +51,7 @@ class WorkoutRepositoryTest {
                 ),
                 DraftExercise(
                     id = UuidV7.generateString(),
-                    type = ExerciseType.Run,
+                    type = ExerciseType.Distance,
                     name = "Easy run",
                     isActive = true
                 )
@@ -70,7 +70,7 @@ class WorkoutRepositoryTest {
         assertTrue(exercises[0].isActive)
         assertEquals("", exercises[0].notes)
         assertEquals("Easy run", exercises[1].name)
-        assertEquals("run", exercises[1].type)
+        assertEquals("distance", exercises[1].type)
         assertEquals(1, exercises[1].sortOrder)
         assertTrue(exercises[1].isActive)
         assertEquals("", exercises[1].notes)
@@ -86,7 +86,7 @@ class WorkoutRepositoryTest {
             name = "Push day",
             exercises = listOf(
                 DraftExercise(id = firstId, type = ExerciseType.Weights, name = "Bench"),
-                DraftExercise(id = secondId, type = ExerciseType.Run, name = "Easy run")
+                DraftExercise(id = secondId, type = ExerciseType.Distance, name = "Easy run")
             )
         )
         val thirdId = UuidV7.generateString()
@@ -95,7 +95,7 @@ class WorkoutRepositoryTest {
             id = workoutId,
             name = " Pull day ",
             exercises = listOf(
-                DraftExercise(id = secondId, type = ExerciseType.Run, name = "Intervals"),
+                DraftExercise(id = secondId, type = ExerciseType.Distance, name = "Intervals"),
                 DraftExercise(id = thirdId, type = ExerciseType.Weights, name = "Row")
             )
         )
@@ -120,7 +120,7 @@ class WorkoutRepositoryTest {
             name = "Push day",
             exercises = listOf(
                 DraftExercise(id = benchId, type = ExerciseType.Weights, name = "Bench"),
-                DraftExercise(id = runId, type = ExerciseType.Run, name = "Easy run")
+                DraftExercise(id = runId, type = ExerciseType.Distance, name = "Easy run")
             )
         )
         val startedAt = 1_700_000_000_000L
@@ -143,8 +143,9 @@ class WorkoutRepositoryTest {
                 SessionExerciseInput(
                     workoutExerciseId = runId,
                     name = "Easy run",
-                    type = ExerciseType.Run,
-                    sets = emptyList()
+                    type = ExerciseType.Distance,
+                    distanceMeters = 5200.0,
+                    durationSeconds = 1930
                 )
             )
         )
@@ -163,6 +164,10 @@ class WorkoutRepositoryTest {
         assertEquals(0, sessionExercises[0].sortOrder)
         assertEquals(runId, sessionExercises[1].workoutExerciseId)
         assertEquals(1, sessionExercises[1].sortOrder)
+        assertEquals(5200.0, sessionExercises[1].distanceMeters, 0.0)
+        assertEquals(1930, sessionExercises[1].durationSeconds)
+        assertEquals(0.0, sessionExercises[0].distanceMeters, 0.0)
+        assertEquals(0, sessionExercises[0].durationSeconds)
 
         val benchSets = database.sessionDao().getSessionSets(sessionExercises[0].id)
         assertEquals(2, benchSets.size)

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

@@ -25,4 +25,6 @@ data class SessionExerciseEntity(
     val name: String,
     val type: String,
     @ColumnInfo(name = "sort_order") val sortOrder: Int,
+    @ColumnInfo(name = "distance_meters") val distanceMeters: Double,
+    @ColumnInfo(name = "duration_seconds") val durationSeconds: Int,
 )

+ 61 - 2
app/src/main/java/com/example/torus/data/TorusDatabase.kt

@@ -15,7 +15,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
         SessionExerciseEntity::class,
         SessionSetEntity::class
     ],
-    version = 4,
+    version = 7,
     exportSchema = false
 )
 abstract class TorusDatabase : RoomDatabase() {
@@ -79,12 +79,71 @@ abstract class TorusDatabase : RoomDatabase() {
             }
         }
 
+        private val MIGRATION_4_5 = object : Migration(4, 5) {
+            override fun migrate(db: SupportSQLiteDatabase) {
+                db.execSQL("UPDATE workout_exercise SET type = 'distance' WHERE type = 'run'")
+                db.execSQL("UPDATE session_exercise SET type = 'distance' WHERE type = 'run'")
+            }
+        }
+
+        private val MIGRATION_5_6 = object : Migration(5, 6) {
+            override fun migrate(db: SupportSQLiteDatabase) {
+                db.execSQL(
+                    "ALTER TABLE session_exercise ADD COLUMN distance TEXT NOT NULL DEFAULT ''"
+                )
+                db.execSQL(
+                    "ALTER TABLE session_exercise ADD COLUMN duration TEXT NOT NULL DEFAULT ''"
+                )
+            }
+        }
+
+        private val MIGRATION_6_7 = object : Migration(6, 7) {
+            override fun migrate(db: SupportSQLiteDatabase) {
+                db.execSQL(
+                    """
+                    CREATE TABLE IF NOT EXISTS session_exercise_new (
+                        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,
+                        distance_meters REAL NOT NULL,
+                        duration_seconds INTEGER NOT NULL,
+                        FOREIGN KEY(session_id) REFERENCES session(id) ON DELETE CASCADE
+                    )
+                    """.trimIndent()
+                )
+                db.execSQL(
+                    """
+                    INSERT INTO session_exercise_new (
+                        id, session_id, workout_exercise_id, name, type, sort_order,
+                        distance_meters, duration_seconds
+                    )
+                    SELECT id, session_id, workout_exercise_id, name, type, sort_order, 0, 0
+                    FROM session_exercise
+                    """.trimIndent()
+                )
+                db.execSQL("DROP TABLE session_exercise")
+                db.execSQL("ALTER TABLE session_exercise_new RENAME TO session_exercise")
+                db.execSQL(
+                    "CREATE INDEX IF NOT EXISTS index_session_exercise_session_id ON session_exercise(session_id)"
+                )
+            }
+        }
+
         fun create(context: Context): TorusDatabase {
             return Room.databaseBuilder(
                 context.applicationContext,
                 TorusDatabase::class.java,
                 "torus.db"
-            ).addMigrations(MIGRATION_2_3, MIGRATION_3_4).build()
+            ).addMigrations(
+                MIGRATION_2_3,
+                MIGRATION_3_4,
+                MIGRATION_4_5,
+                MIGRATION_5_6,
+                MIGRATION_6_7
+            ).build()
         }
     }
 }

+ 3 - 1
app/src/main/java/com/example/torus/data/WorkoutRepository.kt

@@ -119,7 +119,9 @@ class WorkoutRepository(private val database: TorusDatabase) {
                     workoutExerciseId = exercise.workoutExerciseId,
                     name = exercise.name,
                     type = exercise.type.storageValue,
-                    sortOrder = index
+                    sortOrder = index,
+                    distanceMeters = exercise.distanceMeters,
+                    durationSeconds = exercise.durationSeconds
                 )
             }
             if (sessionExercises.isNotEmpty()) {

+ 21 - 0
app/src/main/java/com/example/torus/model/DistanceUnit.kt

@@ -0,0 +1,21 @@
+package com.example.torus.model
+
+enum class DistanceUnit(val metersPerUnit: Double) {
+    Meters(1.0),
+    Kilometers(1000.0),
+    Miles(1609.344),
+    Yards(0.9144),
+    Feet(0.3048),
+}
+
+fun parseDistanceMeters(amount: String, unit: DistanceUnit): Double {
+    val value = amount.toDoubleOrNull() ?: return 0.0
+    return value * unit.metersPerUnit
+}
+
+fun parseDurationSeconds(hours: String, minutes: String, seconds: String): Int {
+    val hoursValue = hours.toIntOrNull() ?: 0
+    val minutesValue = minutes.toIntOrNull() ?: 0
+    val secondsValue = seconds.toIntOrNull() ?: 0
+    return hoursValue * 3600 + minutesValue * 60 + secondsValue
+}

+ 5 - 2
app/src/main/java/com/example/torus/model/ExerciseType.kt

@@ -2,11 +2,14 @@ package com.example.torus.model
 
 enum class ExerciseType(val storageValue: String) {
     Weights("weights"),
-    Run("run");
+    Distance("distance");
 
     companion object {
         fun fromStorage(value: String): ExerciseType {
-            return entries.first { it.storageValue == value }
+            return when (value) {
+                "run" -> Distance
+                else -> entries.first { it.storageValue == value }
+            }
         }
     }
 }

+ 3 - 1
app/src/main/java/com/example/torus/model/SessionInput.kt

@@ -4,7 +4,9 @@ data class SessionExerciseInput(
     val workoutExerciseId: String,
     val name: String,
     val type: ExerciseType,
-    val sets: List<SessionSetInput>,
+    val sets: List<SessionSetInput> = emptyList(),
+    val distanceMeters: Double = 0.0,
+    val durationSeconds: Int = 0,
 )
 
 data class SessionSetInput(

+ 34 - 16
app/src/main/java/com/example/torus/ui/CreateWorkoutScreen.kt

@@ -11,6 +11,7 @@ import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Button
 import androidx.compose.material3.ButtonDefaults
 import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -37,7 +38,8 @@ import kotlinx.coroutines.launch
 @Composable
 fun CreateWorkoutScreen(
     modifier: Modifier = Modifier,
-    onSaveWorkout: (workoutName: String, exercises: List<DraftExercise>) -> Unit = { _, _ -> }
+    onSaveWorkout: (workoutName: String, exercises: List<DraftExercise>) -> Unit = { _, _ -> },
+    onCancel: () -> Unit = {}
 ) {
     var workoutName by rememberSaveable { mutableStateOf("") }
     var showExerciseTypes by rememberSaveable { mutableStateOf(false) }
@@ -102,28 +104,44 @@ fun CreateWorkoutScreen(
             if (showExerciseTypes) {
                 ExerciseTypePicker(
                     onWeightsClick = { addExercise(ExerciseType.Weights) },
-                    onRunClick = { addExercise(ExerciseType.Run) }
+                    onDistanceClick = { addExercise(ExerciseType.Distance) }
                 )
             }
         }
-        Button(
-            onClick = {
-                if (!isSaving) {
-                    isSaving = true
-                    onSaveWorkout(workoutName, exercises.toList())
-                }
-            },
-            enabled = !isSaving,
+        Column(
             modifier = Modifier
                 .fillMaxWidth()
                 .padding(horizontal = 16.dp, vertical = 12.dp),
-            shape = RoundedCornerShape(50),
-            colors = ButtonDefaults.buttonColors(
-                containerColor = MaterialTheme.colorScheme.primary,
-                contentColor = MaterialTheme.colorScheme.onPrimary
-            )
+            verticalArrangement = Arrangement.spacedBy(8.dp)
         ) {
-            Text(text = stringResource(R.string.save_workout))
+            OutlinedButton(
+                onClick = onCancel,
+                enabled = !isSaving,
+                modifier = Modifier.fillMaxWidth(),
+                shape = RoundedCornerShape(50),
+                colors = ButtonDefaults.outlinedButtonColors(
+                    contentColor = MaterialTheme.colorScheme.onBackground
+                )
+            ) {
+                Text(text = stringResource(R.string.cancel))
+            }
+            Button(
+                onClick = {
+                    if (!isSaving) {
+                        isSaving = true
+                        onSaveWorkout(workoutName, exercises.toList())
+                    }
+                },
+                enabled = !isSaving,
+                modifier = Modifier.fillMaxWidth(),
+                shape = RoundedCornerShape(50),
+                colors = ButtonDefaults.buttonColors(
+                    containerColor = MaterialTheme.colorScheme.primary,
+                    contentColor = MaterialTheme.colorScheme.onPrimary
+                )
+            ) {
+                Text(text = stringResource(R.string.save_workout))
+            }
         }
     }
 }

+ 1 - 1
app/src/main/java/com/example/torus/ui/EditWorkoutScreen.kt

@@ -129,7 +129,7 @@ fun EditWorkoutScreen(
             if (showExerciseTypes) {
                 ExerciseTypePicker(
                     onWeightsClick = { addExercise(ExerciseType.Weights) },
-                    onRunClick = { addExercise(ExerciseType.Run) }
+                    onDistanceClick = { addExercise(ExerciseType.Distance) }
                 )
             }
         }

+ 1 - 1
app/src/main/java/com/example/torus/ui/ExerciseTypeLabels.kt

@@ -6,5 +6,5 @@ import com.example.torus.model.ExerciseType
 val ExerciseType.labelRes: Int
     get() = when (this) {
         ExerciseType.Weights -> R.string.exercise_type_weights
-        ExerciseType.Run -> R.string.exercise_type_run
+        ExerciseType.Distance -> R.string.exercise_type_distance
     }

+ 3 - 3
app/src/main/java/com/example/torus/ui/HomeScreen.kt

@@ -26,12 +26,12 @@ import com.example.torus.ui.theme.TorusTheme
 fun HomeScreen(
     workouts: List<WorkoutSummary>,
     modifier: Modifier = Modifier,
-    onAddExercise: () -> Unit = {},
+    onAddWorkout: () -> Unit = {},
     onWorkoutClick: (String) -> Unit = {}
 ) {
     Column(modifier = modifier.fillMaxSize()) {
         Button(
-            onClick = onAddExercise,
+            onClick = onAddWorkout,
             modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
             shape = RoundedCornerShape(50),
             colors = ButtonDefaults.buttonColors(
@@ -39,7 +39,7 @@ fun HomeScreen(
                 contentColor = MaterialTheme.colorScheme.onPrimary
             )
         ) {
-            Text(text = stringResource(R.string.add_exercise))
+            Text(text = stringResource(R.string.add_workout))
         }
         LazyColumn(
             modifier = Modifier

+ 201 - 32
app/src/main/java/com/example/torus/ui/StartWorkoutScreen.kt

@@ -15,10 +15,15 @@ import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Button
 import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.ModalBottomSheet
 import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
@@ -41,7 +46,10 @@ import androidx.compose.ui.unit.dp
 import com.example.torus.R
 import com.example.torus.data.WorkoutRepository
 import com.example.torus.id.UuidV7
+import com.example.torus.model.DistanceUnit
 import com.example.torus.model.ExerciseType
+import com.example.torus.model.parseDistanceMeters
+import com.example.torus.model.parseDurationSeconds
 import com.example.torus.model.SavedExercise
 import com.example.torus.model.SessionExerciseInput
 import com.example.torus.model.SessionSetInput
@@ -57,6 +65,14 @@ private data class WeightSetDraft(
     val reps: String = ""
 )
 
+private data class DistanceDraft(
+    val amount: String = "",
+    val unit: DistanceUnit = DistanceUnit.Kilometers,
+    val hours: String = "",
+    val minutes: String = "",
+    val seconds: String = ""
+)
+
 @Composable
 fun StartWorkoutScreen(
     workoutId: String,
@@ -71,15 +87,20 @@ fun StartWorkoutScreen(
     val setsByExercise = remember(workoutId) {
         mutableStateMapOf<String, SnapshotStateList<WeightSetDraft>>()
     }
+    val distanceByExercise = remember(workoutId) {
+        mutableStateMapOf<String, DistanceDraft>()
+    }
 
     LaunchedEffect(workoutId) {
         val workout = workoutRepository.getWorkoutDetail(workoutId)
         detail = workout
         exerciseIndex = 0
         setsByExercise.clear()
+        distanceByExercise.clear()
         workout?.exercises?.forEach { exercise ->
-            if (exercise.type == ExerciseType.Weights) {
-                setsByExercise[exercise.id] = defaultWeightSets()
+            when (exercise.type) {
+                ExerciseType.Weights -> setsByExercise[exercise.id] = defaultWeightSets()
+                ExerciseType.Distance -> distanceByExercise[exercise.id] = DistanceDraft()
             }
         }
     }
@@ -89,6 +110,7 @@ fun StartWorkoutScreen(
         workout = workout,
         exerciseIndex = exerciseIndex,
         setsByExercise = setsByExercise,
+        distanceByExercise = distanceByExercise,
         isCompleting = isCompleting,
         modifier = modifier,
         onNextExercise = { exerciseIndex += 1 },
@@ -96,7 +118,10 @@ fun StartWorkoutScreen(
         onCompleteWorkout = {
             if (!isCompleting) {
                 isCompleting = true
-                onCompleteWorkout(startedAt, workout.toSessionInput(setsByExercise))
+                onCompleteWorkout(
+                    startedAt,
+                    workout.toSessionInput(setsByExercise, distanceByExercise)
+                )
             }
         }
     )
@@ -107,6 +132,7 @@ private fun StartWorkoutContent(
     workout: WorkoutDetail,
     exerciseIndex: Int,
     setsByExercise: MutableMap<String, SnapshotStateList<WeightSetDraft>>,
+    distanceByExercise: MutableMap<String, DistanceDraft>,
     isCompleting: Boolean = false,
     modifier: Modifier = Modifier,
     onNextExercise: () -> Unit = {},
@@ -169,23 +195,34 @@ private fun StartWorkoutContent(
                     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 }
+                when (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) }
                             }
-                        },
-                        onWeightChange = { setId, weight ->
-                            updateSet(sets, setId) { it.copy(weight = weight) }
-                        },
-                        onRepsChange = { setId, reps ->
-                            updateSet(sets, setId) { it.copy(reps = reps) }
-                        }
-                    )
+                        )
+                    }
+                    ExerciseType.Distance -> {
+                        val draft = distanceByExercise.getOrPut(exercise.id) { DistanceDraft() }
+                        DistanceInputs(
+                            draft = draft,
+                            onChange = { updated ->
+                                distanceByExercise[exercise.id] = updated
+                            }
+                        )
+                    }
                 }
             }
         }
@@ -228,6 +265,7 @@ private fun StartWorkoutContent(
             exercises = exercises,
             currentIndex = exerciseIndex.coerceIn(0, exercises.lastIndex),
             setsByExercise = setsByExercise,
+            distanceByExercise = distanceByExercise,
             onSelect = { index ->
                 onJumpToExercise(index)
                 showExerciseMenu = false
@@ -330,6 +368,7 @@ private fun ExerciseJumpMenu(
     exercises: List<SavedExercise>,
     currentIndex: Int,
     setsByExercise: Map<String, List<WeightSetDraft>>,
+    distanceByExercise: Map<String, DistanceDraft>,
     onSelect: (Int) -> Unit,
     onDismiss: () -> Unit
 ) {
@@ -353,7 +392,7 @@ private fun ExerciseJumpMenu(
             )
             exercises.forEachIndexed { index, exercise ->
                 val selected = index == currentIndex
-                val completed = exercise.hasCompletedSets(setsByExercise)
+                val completed = exercise.isComplete(setsByExercise, distanceByExercise)
                 val shape = RoundedCornerShape(14.dp)
                 Row(
                     modifier = Modifier
@@ -412,15 +451,136 @@ private fun ExerciseJumpMenu(
     }
 }
 
-private fun SavedExercise.hasCompletedSets(
-    setsByExercise: Map<String, List<WeightSetDraft>>
-): Boolean {
-    if (type != ExerciseType.Weights) {
-        return false
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DistanceInputs(
+    draft: DistanceDraft,
+    onChange: (DistanceDraft) -> Unit
+) {
+    var unitMenuExpanded by remember { mutableStateOf(false) }
+    val shape = RoundedCornerShape(14.dp)
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .border(1.dp, MaterialTheme.colorScheme.outline, shape)
+            .padding(16.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp)
+    ) {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+            verticalAlignment = Alignment.Top
+        ) {
+            TorusTextField(
+                value = draft.amount,
+                onValueChange = { onChange(draft.copy(amount = filterDecimal(it))) },
+                label = stringResource(R.string.distance),
+                modifier = Modifier.weight(1f),
+                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal)
+            )
+            ExposedDropdownMenuBox(
+                expanded = unitMenuExpanded,
+                onExpandedChange = { unitMenuExpanded = it },
+                modifier = Modifier.weight(1f)
+            ) {
+                OutlinedTextField(
+                    value = stringResource(draft.unit.labelRes),
+                    onValueChange = {},
+                    modifier = Modifier
+                        .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
+                        .fillMaxWidth(),
+                    readOnly = true,
+                    label = { Text(text = stringResource(R.string.distance_unit)) },
+                    trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(unitMenuExpanded) },
+                    singleLine = true,
+                    shape = RoundedCornerShape(14.dp)
+                )
+                ExposedDropdownMenu(
+                    expanded = unitMenuExpanded,
+                    onDismissRequest = { unitMenuExpanded = false }
+                ) {
+                    DistanceUnit.entries.forEach { unit ->
+                        DropdownMenuItem(
+                            text = { Text(text = stringResource(unit.labelRes)) },
+                            onClick = {
+                                onChange(draft.copy(unit = unit))
+                                unitMenuExpanded = false
+                            }
+                        )
+                    }
+                }
+            }
+        }
+        Text(
+            text = stringResource(R.string.duration),
+            color = MaterialTheme.colorScheme.onSurfaceVariant,
+            style = MaterialTheme.typography.labelLarge
+        )
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            TorusTextField(
+                value = draft.hours,
+                onValueChange = { onChange(draft.copy(hours = it.filter(Char::isDigit))) },
+                label = stringResource(R.string.hours),
+                modifier = Modifier.weight(1f),
+                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+            )
+            Text(
+                text = ":",
+                color = MaterialTheme.colorScheme.onBackground,
+                style = MaterialTheme.typography.headlineSmall
+            )
+            TorusTextField(
+                value = draft.minutes,
+                onValueChange = { onChange(draft.copy(minutes = it.filter(Char::isDigit))) },
+                label = stringResource(R.string.minutes),
+                modifier = Modifier.weight(1f),
+                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+            )
+            Text(
+                text = ":",
+                color = MaterialTheme.colorScheme.onBackground,
+                style = MaterialTheme.typography.headlineSmall
+            )
+            TorusTextField(
+                value = draft.seconds,
+                onValueChange = { onChange(draft.copy(seconds = it.filter(Char::isDigit))) },
+                label = stringResource(R.string.seconds),
+                modifier = Modifier.weight(1f),
+                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+            )
+        }
     }
-    val sets = setsByExercise[id].orEmpty()
-    return sets.isNotEmpty() && sets.all { set ->
-        set.weight.isNotBlank() && set.reps.isNotBlank()
+}
+
+private val DistanceUnit.labelRes: Int
+    get() = when (this) {
+        DistanceUnit.Meters -> R.string.unit_meters
+        DistanceUnit.Kilometers -> R.string.unit_kilometers
+        DistanceUnit.Miles -> R.string.unit_miles
+        DistanceUnit.Yards -> R.string.unit_yards
+        DistanceUnit.Feet -> R.string.unit_feet
+    }
+
+private fun SavedExercise.isComplete(
+    setsByExercise: Map<String, List<WeightSetDraft>>,
+    distanceByExercise: Map<String, DistanceDraft>
+): Boolean {
+    return when (type) {
+        ExerciseType.Weights -> {
+            val sets = setsByExercise[id].orEmpty()
+            sets.isNotEmpty() && sets.all { set ->
+                set.weight.isNotBlank() && set.reps.isNotBlank()
+            }
+        }
+        ExerciseType.Distance -> {
+            val draft = distanceByExercise[id] ?: return false
+            draft.amount.isNotBlank() &&
+                (draft.hours.isNotBlank() || draft.minutes.isNotBlank() || draft.seconds.isNotBlank())
+        }
     }
 }
 
@@ -444,7 +604,8 @@ private fun updateSet(
 }
 
 private fun WorkoutDetail.toSessionInput(
-    setsByExercise: Map<String, List<WeightSetDraft>>
+    setsByExercise: Map<String, List<WeightSetDraft>>,
+    distanceByExercise: Map<String, DistanceDraft>
 ): List<SessionExerciseInput> {
     return exercises.map { exercise ->
         val sets = if (exercise.type == ExerciseType.Weights) {
@@ -454,11 +615,18 @@ private fun WorkoutDetail.toSessionInput(
         } else {
             emptyList()
         }
+        val distanceLog = distanceByExercise[exercise.id] ?: DistanceDraft()
         SessionExerciseInput(
             workoutExerciseId = exercise.id,
             name = exercise.name,
             type = exercise.type,
-            sets = sets
+            sets = sets,
+            distanceMeters = parseDistanceMeters(distanceLog.amount, distanceLog.unit),
+            durationSeconds = parseDurationSeconds(
+                distanceLog.hours,
+                distanceLog.minutes,
+                distanceLog.seconds
+            )
         )
     }
 }
@@ -492,7 +660,7 @@ private fun StartWorkoutContentPreview() {
                     SavedExercise(
                         id = "b",
                         name = "Easy run",
-                        type = ExerciseType.Run,
+                        type = ExerciseType.Distance,
                         sortOrder = 1,
                         isActive = true,
                         notes = ""
@@ -500,7 +668,8 @@ private fun StartWorkoutContentPreview() {
                 )
             ),
             exerciseIndex = 0,
-            setsByExercise = mutableStateMapOf()
+            setsByExercise = mutableStateMapOf(),
+            distanceByExercise = mutableStateMapOf()
         )
     }
 }

+ 3 - 2
app/src/main/java/com/example/torus/ui/TorusApp.kt

@@ -84,7 +84,7 @@ fun TorusApp(workoutRepository: WorkoutRepository) {
             currentRoute == Routes.HOME -> HomeScreen(
                 workouts = workouts,
                 modifier = contentModifier,
-                onAddExercise = { navigateTo(Routes.CREATE) },
+                onAddWorkout = { navigateTo(Routes.CREATE) },
                 onWorkoutClick = { workoutId -> navigateTo(Routes.workout(workoutId)) }
             )
             currentRoute == Routes.CREATE -> CreateWorkoutScreen(
@@ -94,7 +94,8 @@ fun TorusApp(workoutRepository: WorkoutRepository) {
                         workoutRepository.saveWorkout(name, exercises)
                         goHome()
                     }
-                }
+                },
+                onCancel = { popBack() }
             )
             Routes.isWorkout(currentRoute) -> {
                 val workoutId = Routes.workoutId(currentRoute)

+ 3 - 3
app/src/main/java/com/example/torus/ui/WorkoutEditorComponents.kt

@@ -119,7 +119,7 @@ fun NamedExerciseEditor(
 @Composable
 fun ExerciseTypePicker(
     onWeightsClick: () -> Unit,
-    onRunClick: () -> Unit = {}
+    onDistanceClick: () -> Unit = {}
 ) {
     val shape = RoundedCornerShape(14.dp)
     Column(
@@ -140,8 +140,8 @@ fun ExerciseTypePicker(
             onClick = onWeightsClick
         )
         ExerciseTypeOption(
-            label = stringResource(R.string.exercise_type_run),
-            onClick = onRunClick
+            label = stringResource(R.string.exercise_type_distance),
+            onClick = onDistanceClick
         )
     }
 }

+ 14 - 1
app/src/main/res/values/strings.xml

@@ -2,11 +2,13 @@
     <string name="app_name">Torus</string>
     <string name="logo_content_description">Torus logo</string>
     <string name="add_exercise">Add exercise</string>
+    <string name="add_workout">Add workout</string>
+    <string name="cancel">Cancel</string>
     <string name="create_workout">Create Workout</string>
     <string name="workout_name">Workout name</string>
     <string name="choose_exercise_type">What type of exercise?</string>
     <string name="exercise_type_weights">Weights</string>
-    <string name="exercise_type_run">Run</string>
+    <string name="exercise_type_distance">Distance</string>
     <string name="exercise_name">Exercise name</string>
     <string name="save_workout">Save Workout</string>
     <string name="untitled_workout">Untitled workout</string>
@@ -21,6 +23,17 @@
     <string name="set_number">Set %1$d</string>
     <string name="weight">Weight</string>
     <string name="reps">Reps</string>
+    <string name="distance">Distance</string>
+    <string name="duration">Time</string>
+    <string name="distance_unit">Unit</string>
+    <string name="hours">HH</string>
+    <string name="minutes">MM</string>
+    <string name="seconds">SS</string>
+    <string name="unit_meters">Meters</string>
+    <string name="unit_kilometers">Kilometers</string>
+    <string name="unit_miles">Miles</string>
+    <string name="unit_yards">Yards</string>
+    <string name="unit_feet">Feet</string>
     <string name="add_set">Add set</string>
     <string name="remove_set">Remove set</string>
     <string name="next_exercise">Next exercise</string>

+ 27 - 0
app/src/test/java/com/example/torus/DistanceConversionTest.kt

@@ -0,0 +1,27 @@
+package com.example.torus
+
+import com.example.torus.model.DistanceUnit
+import com.example.torus.model.parseDistanceMeters
+import com.example.torus.model.parseDurationSeconds
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class DistanceConversionTest {
+    @Test
+    fun parseDistanceMeters_convertsEachUnit() {
+        assertEquals(0.0, parseDistanceMeters("", DistanceUnit.Meters), 0.0)
+        assertEquals(12.5, parseDistanceMeters("12.5", DistanceUnit.Meters), 0.0)
+        assertEquals(5000.0, parseDistanceMeters("5", DistanceUnit.Kilometers), 0.0)
+        assertEquals(1609.344, parseDistanceMeters("1", DistanceUnit.Miles), 0.0001)
+        assertEquals(91.44, parseDistanceMeters("100", DistanceUnit.Yards), 0.0001)
+        assertEquals(3.048, parseDistanceMeters("10", DistanceUnit.Feet), 0.0001)
+    }
+
+    @Test
+    fun parseDurationSeconds_usesHoursMinutesSeconds() {
+        assertEquals(0, parseDurationSeconds("", "", ""))
+        assertEquals(3723, parseDurationSeconds("1", "2", "3"))
+        assertEquals(150, parseDurationSeconds("", "2", "30"))
+        assertEquals(45, parseDurationSeconds("", "", "45"))
+    }
+}

+ 3 - 2
app/src/test/java/com/example/torus/ExerciseTypeTest.kt

@@ -8,8 +8,9 @@ class ExerciseTypeTest {
     @Test
     fun storageValues_areStable() {
         assertEquals("weights", ExerciseType.Weights.storageValue)
-        assertEquals("run", ExerciseType.Run.storageValue)
+        assertEquals("distance", ExerciseType.Distance.storageValue)
         assertEquals(ExerciseType.Weights, ExerciseType.fromStorage("weights"))
-        assertEquals(ExerciseType.Run, ExerciseType.fromStorage("run"))
+        assertEquals(ExerciseType.Distance, ExerciseType.fromStorage("distance"))
+        assertEquals(ExerciseType.Distance, ExerciseType.fromStorage("run"))
     }
 }