15 Revize 073ce6b58d ... 6c0d61f720

Autor SHA1 Zpráva Datum
  Lee Morgan 6c0d61f720 Compress stuff at the top of session page to reduce vertical space před 2 týdny
  Lee Morgan e443c32247 Add temporary workout saving and ability to resume. před 2 týdny
  Lee Morgan c369158596 Add autofill option před 2 týdny
  Lee Morgan 27464e8ee2 Display previous exercise data during sessions před 2 týdny
  Lee Morgan 34cbac8d41 Add distance information to database. před 2 týdny
  Lee Morgan 44aafeb22e Display a menu to switch between exercises. před 2 týdny
  Lee Morgan 7b66763aff Add completing/saving of sessions před 2 týdny
  Lee Morgan a1602883e6 Create basics of the workout recording před 2 týdny
  Lee Morgan c9bbf601e8 Add ability to edit a workout. před 2 týdny
  Lee Morgan 86b1e8ac61 Add workout options page před 2 týdny
  Lee Morgan 20d38cb090 Display all workouts on the home page před 3 týdny
  Lee Morgan fb2af50a32 Save new workouts to sqlite před 3 týdny
  Lee Morgan 2024f3d095 Save new workouts to sqlite před 3 týdny
  Lee Morgan aeb233af7e Add the UI for the main page and the new workout page. před 3 týdny
  Lee Morgan c45fd18f1c Create agents.md před 3 týdny
53 změnil soubory, kde provedl 3697 přidání a 95 odebrání
  1. 13 0
      .idea/deviceManager.xml
  2. 1 1
      .idea/misc.xml
  3. 6 0
      .idea/vcs.xml
  4. 48 0
      AGENTS.md
  5. 4 0
      app/build.gradle.kts
  6. 95 0
      app/src/androidTest/java/com/example/torus/HomeScreenTest.kt
  7. 301 0
      app/src/androidTest/java/com/example/torus/WorkoutRepositoryTest.kt
  8. 1 0
      app/src/main/AndroidManifest.xml
  9. 3 29
      app/src/main/java/com/example/torus/MainActivity.kt
  10. 11 0
      app/src/main/java/com/example/torus/TorusApplication.kt
  11. 36 0
      app/src/main/java/com/example/torus/data/DraftSessionDao.kt
  12. 68 0
      app/src/main/java/com/example/torus/data/DraftSessionEntity.kt
  13. 36 0
      app/src/main/java/com/example/torus/data/SessionDao.kt
  14. 26 0
      app/src/main/java/com/example/torus/data/SessionEntity.kt
  15. 30 0
      app/src/main/java/com/example/torus/data/SessionExerciseEntity.kt
  16. 27 0
      app/src/main/java/com/example/torus/data/SessionSetEntity.kt
  17. 203 0
      app/src/main/java/com/example/torus/data/TorusDatabase.kt
  18. 30 0
      app/src/main/java/com/example/torus/data/WorkoutDao.kt
  19. 10 0
      app/src/main/java/com/example/torus/data/WorkoutEntity.kt
  20. 29 0
      app/src/main/java/com/example/torus/data/WorkoutExerciseEntity.kt
  21. 273 0
      app/src/main/java/com/example/torus/data/WorkoutRepository.kt
  22. 37 0
      app/src/main/java/com/example/torus/id/UuidV7.kt
  23. 37 0
      app/src/main/java/com/example/torus/model/DistanceUnit.kt
  24. 9 0
      app/src/main/java/com/example/torus/model/DraftExercise.kt
  25. 15 0
      app/src/main/java/com/example/torus/model/ExerciseType.kt
  26. 26 0
      app/src/main/java/com/example/torus/model/InProgressSession.kt
  27. 41 0
      app/src/main/java/com/example/torus/model/LastExerciseLog.kt
  28. 15 0
      app/src/main/java/com/example/torus/model/SessionInput.kt
  29. 32 0
      app/src/main/java/com/example/torus/model/Workout.kt
  30. 56 0
      app/src/main/java/com/example/torus/ui/AppHeader.kt
  31. 32 0
      app/src/main/java/com/example/torus/ui/ConfirmationBanner.kt
  32. 158 0
      app/src/main/java/com/example/torus/ui/CreateWorkoutScreen.kt
  33. 156 0
      app/src/main/java/com/example/torus/ui/EditWorkoutScreen.kt
  34. 10 0
      app/src/main/java/com/example/torus/ui/ExerciseTypeLabels.kt
  35. 86 0
      app/src/main/java/com/example/torus/ui/HomeScreen.kt
  36. 970 0
      app/src/main/java/com/example/torus/ui/StartWorkoutScreen.kt
  37. 154 0
      app/src/main/java/com/example/torus/ui/TorusApp.kt
  38. 161 0
      app/src/main/java/com/example/torus/ui/WorkoutDetailScreen.kt
  39. 200 0
      app/src/main/java/com/example/torus/ui/WorkoutEditorComponents.kt
  40. 22 7
      app/src/main/java/com/example/torus/ui/theme/Color.kt
  41. 26 45
      app/src/main/java/com/example/torus/ui/theme/Theme.kt
  42. binární
      app/src/main/res/drawable-nodpi/torus.png
  43. 5 8
      app/src/main/res/values/colors.xml
  44. 52 1
      app/src/main/res/values/strings.xml
  45. 6 3
      app/src/main/res/values/themes.xml
  46. 35 0
      app/src/test/java/com/example/torus/DistanceConversionTest.kt
  47. 16 0
      app/src/test/java/com/example/torus/ExerciseTypeTest.kt
  48. 52 0
      app/src/test/java/com/example/torus/LastExerciseFormatTest.kt
  49. 28 0
      app/src/test/java/com/example/torus/UuidV7Test.kt
  50. 1 0
      build.gradle.kts
  51. 2 1
      gradle.properties
  52. 6 0
      gradle/libs.versions.toml
  53. binární
      res/drawable/torus.png

+ 13 - 0
.idea/deviceManager.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="DeviceTable">
+    <option name="columnSorters">
+      <list>
+        <ColumnSorterState>
+          <option name="column" value="Name" />
+          <option name="order" value="ASCENDING" />
+        </ColumnSorterState>
+      </list>
+    </option>
+  </component>
+</project>

+ 1 - 1
.idea/misc.xml

@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="ExternalStorageConfigurationManager" enabled="true" />
-  <component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
     <output url="file://$PROJECT_DIR$/build/classes" />
   </component>
   <component name="ProjectType">

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$" vcs="Git" />
+  </component>
+</project>

+ 48 - 0
AGENTS.md

@@ -0,0 +1,48 @@
+# App Description
+This is meant to be an exercise application for all sorts of exercise. It will primarily store data locally. Storing to a database will be an optional upgrade. One of the big purposes of this is to avoid the garbage apps that have tons of ads and other stuff. Nice and simple and easy to use for any type of exercise.
+
+# Code
+Always use Kotlin over Java unless there is no other option
+
+# Style
+
+Dark only. Surfaces stay quiet so effort reads as heat. Prefer borders over shadows.
+
+## Colors
+
+| Role | Token | Hex | Use |
+| --- | --- | --- | --- |
+| Page | `--ink-sunken` | `#0c0f14` | App background |
+| Surface | `--ink` | `#12161c` | Base surface |
+| Surface raised | `--ink-raised` | `#1b222c` | Cards, nav |
+| Text | `--paper` | `#f4efe6` | Primary type. Warm, not pure white |
+| Text dim | `--paper-dim` | `#d8d0c4` | Secondary body copy |
+| Muted | `--mist` | `#8b97a8` | Labels, secondary type |
+| Border | `--line` | `#2c3542` | Dividers |
+| Border strong | `--line-strong` | `#3d4a5c` | Ghost controls, stronger edges |
+| Action | `--ember` | `#e25b3a` | Primary button, active ring, current set |
+| Action pressed | `--ember-deep` | `#b84328` | Pressed or hover on ember |
+| Action tint | `--ember-soft` | `#f3b6a4` | Active nav text, soft ember highlights |
+| Recovery | `--tide` | `#3e8f86` | Rest, completed volume, secondary action |
+| Recovery pressed | `--tide-deep` | `#2a6861` | Pressed or hover on tide |
+| Recovery tint | `--tide-soft` | `#b7d8d4` | Soft tide highlights |
+| Target | `--sand` | `#e7c07a` | Goals, PRs, section kickers. Not a warning |
+| Target deep | `--sand-deep` | `#c4964a` | Pressed sand, stronger target marks |
+| Heart | `--pulse` | `#d4526e` | Heart rate only |
+| Ok | `--ok` | `#6ea36a` | Finished, success. Off the brand hues |
+| Warn | `--warn` | `#d4a24a` | Missed target, open set. Not sand |
+| Danger | `--danger` | `#c44b4b` | Errors, destructive |
+
+Ember button text is `#1a0d09`, not paper. Tide button text is `#071614`.
+
+## Use
+
+- Ink is the room. Ember is the action. Tide is rest and completion. Sand is the marker being chased.
+- Do not use ember, tide, or sand for status. Ok, warn, and danger stay separate so a finished set, a missed target, and a failure do not look like buttons.
+- Pulse is heart rate only. Do not use it for actions or errors.
+- Paper on ink is about 14:1. Ember on ink-sunken is about 5.4:1: large type and rings only, not small body text.
+- Radius `14px` on cards, `999px` on buttons and badges. Shadow, if any, `0 18px 40px rgba(0, 0, 0, 0.28)`.
+
+# Database
+- Use SQLite to store data
+- IDs for all rows in all tables should use UUID v7 for better indexing purposes.

+ 4 - 0
app/build.gradle.kts

@@ -1,6 +1,7 @@
 plugins {
     alias(libs.plugins.android.application)
     alias(libs.plugins.kotlin.compose)
+    alias(libs.plugins.ksp)
 }
 
 android {
@@ -44,6 +45,9 @@ dependencies {
     implementation(libs.androidx.compose.ui.tooling.preview)
     implementation(libs.androidx.core.ktx)
     implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.room.ktx)
+    implementation(libs.androidx.room.runtime)
+    ksp(libs.androidx.room.compiler)
     testImplementation(libs.junit)
     androidTestImplementation(platform(libs.androidx.compose.bom))
     androidTestImplementation(libs.androidx.compose.ui.test.junit4)

+ 95 - 0
app/src/androidTest/java/com/example/torus/HomeScreenTest.kt

@@ -0,0 +1,95 @@
+package com.example.torus
+
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertDoesNotExist
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import org.junit.Rule
+import org.junit.Test
+
+class HomeScreenTest {
+    @get:Rule
+    val composeTestRule = createAndroidComposeRule<MainActivity>()
+
+    @Test
+    fun homeScreen_displaysLogoAndTitle() {
+        composeTestRule.onNodeWithText("Torus").assertIsDisplayed()
+        composeTestRule.onNodeWithContentDescription("Torus logo").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Add workout").assertIsDisplayed()
+    }
+
+    @Test
+    fun addWorkout_opensCreateWorkoutPage() {
+        composeTestRule.onNodeWithText("Add workout").performClick()
+        composeTestRule.onNodeWithText("Create Workout").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Save Workout").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Torus").assertIsDisplayed()
+        composeTestRule.onNodeWithContentDescription("Torus logo").assertIsDisplayed()
+    }
+
+    @Test
+    fun headerClick_returnsToHome() {
+        composeTestRule.onNodeWithText("Add workout").performClick()
+        composeTestRule.onNodeWithText("Create Workout").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Torus").performClick()
+        composeTestRule.onNodeWithText("Add workout").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Create Workout").assertDoesNotExist()
+    }
+
+    @Test
+    fun createWorkout_showsNameFieldAndExerciseTypes() {
+        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("Distance").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Weights").performClick()
+        composeTestRule.onNodeWithText("Create Workout").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_canAddAnotherWeightsExercise() {
+        composeTestRule.onNodeWithText("Add workout").performClick()
+        composeTestRule.onNodeWithText("Add exercise").performClick()
+        composeTestRule.onNodeWithText("Weights").performClick()
+        composeTestRule.onNodeWithText("Add exercise").performClick()
+        composeTestRule.onNode(hasText("Weights") and hasClickAction()).performClick()
+        composeTestRule.onAllNodesWithText("Exercise name").assertCountEquals(2)
+        composeTestRule.onNodeWithText("1").assertIsDisplayed()
+        composeTestRule.onNodeWithText("2").assertIsDisplayed()
+        composeTestRule.onNodeWithText("Add exercise").assertIsDisplayed()
+    }
+
+    @Test
+    fun createWorkout_addsDistanceExercise() {
+        composeTestRule.onNodeWithText("Add workout").performClick()
+        composeTestRule.onNodeWithText("Add exercise").performClick()
+        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()
+    }
+}

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

@@ -0,0 +1,301 @@
+package com.example.torus
+
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.example.torus.data.TorusDatabase
+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.InProgressDistanceLog
+import com.example.torus.model.InProgressSession
+import com.example.torus.model.InProgressWeightSet
+import com.example.torus.model.SessionExerciseInput
+import com.example.torus.model.SessionSetInput
+import java.util.UUID
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class WorkoutRepositoryTest {
+    private lateinit var database: TorusDatabase
+    private lateinit var repository: WorkoutRepository
+
+    @Before
+    fun setUp() {
+        database = Room.inMemoryDatabaseBuilder(
+            ApplicationProvider.getApplicationContext(),
+            TorusDatabase::class.java
+        ).allowMainThreadQueries().build()
+        repository = WorkoutRepository(database)
+    }
+
+    @After
+    fun tearDown() {
+        database.close()
+    }
+
+    @Test
+    fun saveWorkout_writesNameAndExercises() = runBlocking {
+        val workoutId = repository.saveWorkout(
+            name = "  Push day  ",
+            exercises = listOf(
+                DraftExercise(
+                    id = UuidV7.generateString(),
+                    type = ExerciseType.Weights,
+                    name = " Bench ",
+                    notes = " pause reps "
+                ),
+                DraftExercise(
+                    id = UuidV7.generateString(),
+                    type = ExerciseType.Distance,
+                    name = "Easy run",
+                    isActive = true
+                )
+            )
+        )
+
+        val workout = database.workoutDao().getWorkout(workoutId)!!
+        assertEquals("Push day", workout.name)
+        assertUuidV7(workout.id)
+
+        val exercises = database.workoutDao().getExercisesForWorkout(workoutId)
+        assertEquals(2, exercises.size)
+        assertEquals("Bench", exercises[0].name)
+        assertEquals("weights", exercises[0].type)
+        assertEquals(0, exercises[0].sortOrder)
+        assertTrue(exercises[0].isActive)
+        assertEquals("", exercises[0].notes)
+        assertEquals("Easy run", exercises[1].name)
+        assertEquals("distance", exercises[1].type)
+        assertEquals(1, exercises[1].sortOrder)
+        assertTrue(exercises[1].isActive)
+        assertEquals("", exercises[1].notes)
+        exercises.forEach { assertUuidV7(it.id) }
+        assertUuidV7(exercises[0].workoutId)
+    }
+
+    @Test
+    fun updateWorkout_renamesReordersAndReplacesExercises() = runBlocking {
+        val firstId = UuidV7.generateString()
+        val secondId = UuidV7.generateString()
+        val workoutId = repository.saveWorkout(
+            name = "Push day",
+            exercises = listOf(
+                DraftExercise(id = firstId, type = ExerciseType.Weights, name = "Bench"),
+                DraftExercise(id = secondId, type = ExerciseType.Distance, name = "Easy run")
+            )
+        )
+        val thirdId = UuidV7.generateString()
+
+        repository.updateWorkout(
+            id = workoutId,
+            name = " Pull day ",
+            exercises = listOf(
+                DraftExercise(id = secondId, type = ExerciseType.Distance, name = "Intervals"),
+                DraftExercise(id = thirdId, type = ExerciseType.Weights, name = "Row")
+            )
+        )
+
+        val workout = database.workoutDao().getWorkout(workoutId)!!
+        assertEquals("Pull day", workout.name)
+        val exercises = database.workoutDao().getExercisesForWorkout(workoutId)
+        assertEquals(2, exercises.size)
+        assertEquals(secondId, exercises[0].id)
+        assertEquals("Intervals", exercises[0].name)
+        assertEquals(0, exercises[0].sortOrder)
+        assertEquals(thirdId, exercises[1].id)
+        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.Distance, 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.Distance,
+                    distanceMeters = 5200.0,
+                    durationSeconds = 1930
+                )
+            )
+        )
+
+        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)
+        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)
+        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))
+    }
+
+    @Test
+    fun getLastExerciseLogs_returnsMostRecentSession() = runBlocking {
+        val benchId = UuidV7.generateString()
+        val workoutId = repository.saveWorkout(
+            name = "Push day",
+            exercises = listOf(
+                DraftExercise(id = benchId, type = ExerciseType.Weights, name = "Bench")
+            )
+        )
+        repository.completeSession(
+            workoutId = workoutId,
+            startedAt = 1L,
+            finishedAt = 2L,
+            exercises = listOf(
+                SessionExerciseInput(
+                    workoutExerciseId = benchId,
+                    name = "Bench",
+                    type = ExerciseType.Weights,
+                    sets = listOf(SessionSetInput(weight = "95", reps = "10"))
+                )
+            )
+        )
+        repository.completeSession(
+            workoutId = workoutId,
+            startedAt = 3L,
+            finishedAt = 4L,
+            exercises = listOf(
+                SessionExerciseInput(
+                    workoutExerciseId = benchId,
+                    name = "Bench",
+                    type = ExerciseType.Weights,
+                    sets = listOf(
+                        SessionSetInput(weight = "135", reps = "8"),
+                        SessionSetInput(weight = "155", reps = "5")
+                    )
+                )
+            )
+        )
+
+        val logs = repository.getLastExerciseLogs(workoutId)
+        val bench = logs.getValue(benchId)
+        assertEquals(2, bench.sets.size)
+        assertEquals("135", bench.sets[0].weight)
+        assertEquals("8", bench.sets[0].reps)
+        assertEquals("155", bench.sets[1].weight)
+        assertEquals("5", bench.sets[1].reps)
+    }
+
+    @Test
+    fun inProgressSession_roundTripsAndClearsOnComplete() = runBlocking {
+        val benchId = UuidV7.generateString()
+        val runId = UuidV7.generateString()
+        val setId = UuidV7.generateString()
+        val workoutId = repository.saveWorkout(
+            name = "Push day",
+            exercises = listOf(
+                DraftExercise(id = benchId, type = ExerciseType.Weights, name = "Bench"),
+                DraftExercise(id = runId, type = ExerciseType.Distance, name = "Easy run")
+            )
+        )
+        val draft = InProgressSession(
+            workoutId = workoutId,
+            startedAt = 99L,
+            currentExerciseIndex = 1,
+            weightSets = listOf(
+                InProgressWeightSet(
+                    id = setId,
+                    workoutExerciseId = benchId,
+                    sortOrder = 0,
+                    weight = "135",
+                    reps = "8"
+                )
+            ),
+            distanceLogs = listOf(
+                InProgressDistanceLog(
+                    workoutExerciseId = runId,
+                    amount = "5",
+                    unit = "Kilometers",
+                    hours = "0",
+                    minutes = "32",
+                    seconds = "10"
+                )
+            )
+        )
+        repository.saveInProgressSession(draft)
+        val loaded = repository.getInProgressSession(workoutId)!!
+        assertEquals(99L, loaded.startedAt)
+        assertEquals(1, loaded.currentExerciseIndex)
+        assertEquals("135", loaded.weightSets.single().weight)
+        assertEquals("8", loaded.weightSets.single().reps)
+        assertEquals("5", loaded.distanceLogs.single().amount)
+        assertEquals("Kilometers", loaded.distanceLogs.single().unit)
+
+        repository.completeSession(
+            workoutId = workoutId,
+            startedAt = 99L,
+            finishedAt = 100L,
+            exercises = listOf(
+                SessionExerciseInput(
+                    workoutExerciseId = benchId,
+                    name = "Bench",
+                    type = ExerciseType.Weights,
+                    sets = listOf(SessionSetInput(weight = "135", reps = "8"))
+                )
+            )
+        )
+        assertNull(repository.getInProgressSession(workoutId))
+    }
+}
+
+private fun assertUuidV7(value: String) {
+    val uuid = UUID.fromString(value)
+    assertEquals(7, uuid.version())
+    assertEquals(2, uuid.variant())
+}

+ 1 - 0
app/src/main/AndroidManifest.xml

@@ -3,6 +3,7 @@
     xmlns:tools="http://schemas.android.com/tools">
 
     <application
+        android:name=".TorusApplication"
         android:allowBackup="true"
         android:dataExtractionRules="@xml/data_extraction_rules"
         android:fullBackupContent="@xml/backup_rules"

+ 3 - 29
app/src/main/java/com/example/torus/MainActivity.kt

@@ -4,44 +4,18 @@ import android.os.Bundle
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
 import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
+import com.example.torus.ui.TorusApp
 import com.example.torus.ui.theme.TorusTheme
 
 class MainActivity : ComponentActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         enableEdgeToEdge()
+        val workoutRepository = (application as TorusApplication).workoutRepository
         setContent {
             TorusTheme {
-                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
-                    Greeting(
-                        name = "Penis Butthole Dude",
-                        modifier = Modifier.padding(innerPadding)
-                    )
-                }
+                TorusApp(workoutRepository = workoutRepository)
             }
         }
     }
 }
-
-@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
-    Text(
-        text = "Hello $name!",
-        modifier = modifier
-    )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun GreetingPreview() {
-    TorusTheme {
-        Greeting("Android")
-    }
-}

+ 11 - 0
app/src/main/java/com/example/torus/TorusApplication.kt

@@ -0,0 +1,11 @@
+package com.example.torus
+
+import android.app.Application
+import com.example.torus.data.TorusDatabase
+import com.example.torus.data.WorkoutRepository
+
+class TorusApplication : Application() {
+    val workoutRepository: WorkoutRepository by lazy {
+        WorkoutRepository(TorusDatabase.create(this))
+    }
+}

+ 36 - 0
app/src/main/java/com/example/torus/data/DraftSessionDao.kt

@@ -0,0 +1,36 @@
+package com.example.torus.data
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+
+@Dao
+interface DraftSessionDao {
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insertSession(session: DraftSessionEntity)
+
+    @Insert
+    suspend fun insertWeightSets(sets: List<DraftWeightSetEntity>)
+
+    @Insert
+    suspend fun insertDistanceLogs(logs: List<DraftDistanceEntity>)
+
+    @Query("DELETE FROM draft_session WHERE workout_id = :workoutId")
+    suspend fun deleteSession(workoutId: String)
+
+    @Query("SELECT * FROM draft_session WHERE workout_id = :workoutId")
+    suspend fun getSession(workoutId: String): DraftSessionEntity?
+
+    @Query(
+        """
+        SELECT * FROM draft_weight_set
+        WHERE workout_id = :workoutId
+        ORDER BY workout_exercise_id ASC, sort_order ASC
+        """
+    )
+    suspend fun getWeightSets(workoutId: String): List<DraftWeightSetEntity>
+
+    @Query("SELECT * FROM draft_distance WHERE workout_id = :workoutId")
+    suspend fun getDistanceLogs(workoutId: String): List<DraftDistanceEntity>
+}

+ 68 - 0
app/src/main/java/com/example/torus/data/DraftSessionEntity.kt

@@ -0,0 +1,68 @@
+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 = "draft_session",
+    foreignKeys = [
+        ForeignKey(
+            entity = WorkoutEntity::class,
+            parentColumns = ["id"],
+            childColumns = ["workout_id"],
+            onDelete = ForeignKey.CASCADE
+        )
+    ]
+)
+data class DraftSessionEntity(
+    @PrimaryKey @ColumnInfo(name = "workout_id") val workoutId: String,
+    @ColumnInfo(name = "started_at") val startedAt: Long,
+    @ColumnInfo(name = "current_exercise_index") val currentExerciseIndex: Int,
+)
+
+@Entity(
+    tableName = "draft_weight_set",
+    foreignKeys = [
+        ForeignKey(
+            entity = DraftSessionEntity::class,
+            parentColumns = ["workout_id"],
+            childColumns = ["workout_id"],
+            onDelete = ForeignKey.CASCADE
+        )
+    ],
+    indices = [Index("workout_id")]
+)
+data class DraftWeightSetEntity(
+    @PrimaryKey val id: String,
+    @ColumnInfo(name = "workout_id") val workoutId: String,
+    @ColumnInfo(name = "workout_exercise_id") val workoutExerciseId: String,
+    @ColumnInfo(name = "sort_order") val sortOrder: Int,
+    val weight: String,
+    val reps: String,
+)
+
+@Entity(
+    tableName = "draft_distance",
+    primaryKeys = ["workout_id", "workout_exercise_id"],
+    foreignKeys = [
+        ForeignKey(
+            entity = DraftSessionEntity::class,
+            parentColumns = ["workout_id"],
+            childColumns = ["workout_id"],
+            onDelete = ForeignKey.CASCADE
+        )
+    ],
+    indices = [Index("workout_id")]
+)
+data class DraftDistanceEntity(
+    @ColumnInfo(name = "workout_id") val workoutId: String,
+    @ColumnInfo(name = "workout_exercise_id") val workoutExerciseId: String,
+    val amount: String,
+    val unit: String,
+    val hours: String,
+    val minutes: String,
+    val seconds: String,
+)

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

@@ -0,0 +1,36 @@
+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
+        WHERE workout_id = :workoutId
+        ORDER BY finished_at DESC, id DESC
+        LIMIT 1
+        """
+    )
+    suspend fun getLatestSessionForWorkout(workoutId: 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,
+)

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

@@ -0,0 +1,30 @@
+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,
+    @ColumnInfo(name = "distance_meters") val distanceMeters: Double,
+    @ColumnInfo(name = "duration_seconds") val durationSeconds: 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,
+)

+ 203 - 0
app/src/main/java/com/example/torus/data/TorusDatabase.kt

@@ -0,0 +1,203 @@
+package com.example.torus.data
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+@Database(
+    entities = [
+        WorkoutEntity::class,
+        WorkoutExerciseEntity::class,
+        SessionEntity::class,
+        SessionExerciseEntity::class,
+        SessionSetEntity::class,
+        DraftSessionEntity::class,
+        DraftWeightSetEntity::class,
+        DraftDistanceEntity::class
+    ],
+    version = 8,
+    exportSchema = false
+)
+abstract class TorusDatabase : RoomDatabase() {
+    abstract fun workoutDao(): WorkoutDao
+    abstract fun sessionDao(): SessionDao
+    abstract fun draftSessionDao(): DraftSessionDao
+
+    companion object {
+        private val MIGRATION_2_3 = object : Migration(2, 3) {
+            override fun migrate(db: SupportSQLiteDatabase) {
+                db.execSQL(
+                    "ALTER TABLE workout_exercise ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0"
+                )
+            }
+        }
+
+        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)"
+                )
+            }
+        }
+
+        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)"
+                )
+            }
+        }
+
+        private val MIGRATION_7_8 = object : Migration(7, 8) {
+            override fun migrate(db: SupportSQLiteDatabase) {
+                db.execSQL(
+                    """
+                    CREATE TABLE IF NOT EXISTS draft_session (
+                        workout_id TEXT NOT NULL PRIMARY KEY,
+                        started_at INTEGER NOT NULL,
+                        current_exercise_index INTEGER NOT NULL,
+                        FOREIGN KEY(workout_id) REFERENCES workout(id) ON DELETE CASCADE
+                    )
+                    """.trimIndent()
+                )
+                db.execSQL(
+                    """
+                    CREATE TABLE IF NOT EXISTS draft_weight_set (
+                        id TEXT NOT NULL PRIMARY KEY,
+                        workout_id TEXT NOT NULL,
+                        workout_exercise_id TEXT NOT NULL,
+                        sort_order INTEGER NOT NULL,
+                        weight TEXT NOT NULL,
+                        reps TEXT NOT NULL,
+                        FOREIGN KEY(workout_id) REFERENCES draft_session(workout_id) ON DELETE CASCADE
+                    )
+                    """.trimIndent()
+                )
+                db.execSQL(
+                    "CREATE INDEX IF NOT EXISTS index_draft_weight_set_workout_id ON draft_weight_set(workout_id)"
+                )
+                db.execSQL(
+                    """
+                    CREATE TABLE IF NOT EXISTS draft_distance (
+                        workout_id TEXT NOT NULL,
+                        workout_exercise_id TEXT NOT NULL,
+                        amount TEXT NOT NULL,
+                        unit TEXT NOT NULL,
+                        hours TEXT NOT NULL,
+                        minutes TEXT NOT NULL,
+                        seconds TEXT NOT NULL,
+                        PRIMARY KEY(workout_id, workout_exercise_id),
+                        FOREIGN KEY(workout_id) REFERENCES draft_session(workout_id) ON DELETE CASCADE
+                    )
+                    """.trimIndent()
+                )
+                db.execSQL(
+                    "CREATE INDEX IF NOT EXISTS index_draft_distance_workout_id ON draft_distance(workout_id)"
+                )
+            }
+        }
+
+        fun create(context: Context): TorusDatabase {
+            return Room.databaseBuilder(
+                context.applicationContext,
+                TorusDatabase::class.java,
+                "torus.db"
+            ).addMigrations(
+                MIGRATION_2_3,
+                MIGRATION_3_4,
+                MIGRATION_4_5,
+                MIGRATION_5_6,
+                MIGRATION_6_7,
+                MIGRATION_7_8
+            ).build()
+        }
+    }
+}

+ 30 - 0
app/src/main/java/com/example/torus/data/WorkoutDao.kt

@@ -0,0 +1,30 @@
+package com.example.torus.data
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface WorkoutDao {
+    @Insert
+    suspend fun insertWorkout(workout: WorkoutEntity)
+
+    @Insert
+    suspend fun insertExercises(exercises: List<WorkoutExerciseEntity>)
+
+    @Query("UPDATE workout SET name = :name WHERE id = :id")
+    suspend fun updateWorkoutName(id: String, name: String)
+
+    @Query("DELETE FROM workout_exercise WHERE workout_id = :workoutId")
+    suspend fun deleteExercisesForWorkout(workoutId: String)
+
+    @Query("SELECT * FROM workout ORDER BY id DESC")
+    fun observeWorkouts(): Flow<List<WorkoutEntity>>
+
+    @Query("SELECT * FROM workout WHERE id = :id")
+    suspend fun getWorkout(id: String): WorkoutEntity?
+
+    @Query("SELECT * FROM workout_exercise WHERE workout_id = :workoutId ORDER BY sort_order ASC")
+    suspend fun getExercisesForWorkout(workoutId: String): List<WorkoutExerciseEntity>
+}

+ 10 - 0
app/src/main/java/com/example/torus/data/WorkoutEntity.kt

@@ -0,0 +1,10 @@
+package com.example.torus.data
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "workout")
+data class WorkoutEntity(
+    @PrimaryKey val id: String,
+    val name: String,
+)

+ 29 - 0
app/src/main/java/com/example/torus/data/WorkoutExerciseEntity.kt

@@ -0,0 +1,29 @@
+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 = "workout_exercise",
+    foreignKeys = [
+        ForeignKey(
+            entity = WorkoutEntity::class,
+            parentColumns = ["id"],
+            childColumns = ["workout_id"],
+            onDelete = ForeignKey.CASCADE
+        )
+    ],
+    indices = [Index("workout_id")]
+)
+data class WorkoutExerciseEntity(
+    @PrimaryKey val id: String,
+    @ColumnInfo(name = "workout_id") val workoutId: String,
+    val name: String,
+    val type: String,
+    @ColumnInfo(name = "sort_order") val sortOrder: Int,
+    @ColumnInfo(name = "is_active") val isActive: Boolean,
+    val notes: String,
+)

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

@@ -0,0 +1,273 @@
+package com.example.torus.data
+
+import androidx.room.withTransaction
+import com.example.torus.id.UuidV7
+import com.example.torus.model.DraftExercise
+import com.example.torus.model.ExerciseType
+import com.example.torus.model.InProgressDistanceLog
+import com.example.torus.model.InProgressSession
+import com.example.torus.model.InProgressWeightSet
+import com.example.torus.model.LastExerciseLog
+import com.example.torus.model.LastWeightSet
+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
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+class WorkoutRepository(private val database: TorusDatabase) {
+    private val workoutDao = database.workoutDao()
+    private val sessionDao = database.sessionDao()
+    private val draftSessionDao = database.draftSessionDao()
+    private val draftMutex = Mutex()
+    private val discardedDrafts = mutableSetOf<String>()
+
+    fun observeWorkouts(): Flow<List<WorkoutSummary>> {
+        return workoutDao.observeWorkouts().map { workouts ->
+            workouts.map { workout ->
+                WorkoutSummary(id = workout.id, name = workout.name)
+            }
+        }
+    }
+
+    suspend fun getWorkoutDetail(id: String): WorkoutDetail? {
+        val workout = workoutDao.getWorkout(id) ?: return null
+        val exercises = workoutDao.getExercisesForWorkout(id).map { exercise ->
+            SavedExercise(
+                id = exercise.id,
+                name = exercise.name,
+                type = ExerciseType.fromStorage(exercise.type),
+                sortOrder = exercise.sortOrder,
+                isActive = exercise.isActive,
+                notes = exercise.notes
+            )
+        }
+        return WorkoutDetail(
+            id = workout.id,
+            name = workout.name,
+            exercises = exercises
+        )
+    }
+
+    suspend fun getLastExerciseLogs(workoutId: String): Map<String, LastExerciseLog> {
+        val session = sessionDao.getLatestSessionForWorkout(workoutId) ?: return emptyMap()
+        val exercises = sessionDao.getSessionExercises(session.id)
+        return exercises.associate { exercise ->
+            val sets = sessionDao.getSessionSets(exercise.id).map { set ->
+                LastWeightSet(weight = set.weight, reps = set.reps)
+            }
+            exercise.workoutExerciseId to LastExerciseLog(
+                type = ExerciseType.fromStorage(exercise.type),
+                sets = sets,
+                distanceMeters = exercise.distanceMeters,
+                durationSeconds = exercise.durationSeconds
+            )
+        }
+    }
+
+    suspend fun allowInProgressSaves(workoutId: String) {
+        draftMutex.withLock {
+            discardedDrafts.remove(workoutId)
+        }
+    }
+
+    suspend fun hasInProgressSession(workoutId: String): Boolean {
+        return draftMutex.withLock {
+            draftSessionDao.getSession(workoutId) != null
+        }
+    }
+
+    suspend fun discardInProgressSession(workoutId: String) {
+        draftMutex.withLock {
+            discardedDrafts += workoutId
+            draftSessionDao.deleteSession(workoutId)
+        }
+    }
+
+    suspend fun getInProgressSession(workoutId: String): InProgressSession? {
+        return draftMutex.withLock {
+            val session = draftSessionDao.getSession(workoutId) ?: return@withLock null
+            InProgressSession(
+                workoutId = session.workoutId,
+                startedAt = session.startedAt,
+                currentExerciseIndex = session.currentExerciseIndex,
+                weightSets = draftSessionDao.getWeightSets(workoutId).map { set ->
+                    InProgressWeightSet(
+                        id = set.id,
+                        workoutExerciseId = set.workoutExerciseId,
+                        sortOrder = set.sortOrder,
+                        weight = set.weight,
+                        reps = set.reps
+                    )
+                },
+                distanceLogs = draftSessionDao.getDistanceLogs(workoutId).map { log ->
+                    InProgressDistanceLog(
+                        workoutExerciseId = log.workoutExerciseId,
+                        amount = log.amount,
+                        unit = log.unit,
+                        hours = log.hours,
+                        minutes = log.minutes,
+                        seconds = log.seconds
+                    )
+                }
+            )
+        }
+    }
+
+    suspend fun saveInProgressSession(session: InProgressSession) {
+        draftMutex.withLock {
+            if (session.workoutId in discardedDrafts) {
+                return
+            }
+            database.withTransaction {
+                draftSessionDao.deleteSession(session.workoutId)
+                draftSessionDao.insertSession(
+                    DraftSessionEntity(
+                        workoutId = session.workoutId,
+                        startedAt = session.startedAt,
+                        currentExerciseIndex = session.currentExerciseIndex
+                    )
+                )
+                if (session.weightSets.isNotEmpty()) {
+                    draftSessionDao.insertWeightSets(
+                        session.weightSets.map { set ->
+                            DraftWeightSetEntity(
+                                id = set.id,
+                                workoutId = session.workoutId,
+                                workoutExerciseId = set.workoutExerciseId,
+                                sortOrder = set.sortOrder,
+                                weight = set.weight,
+                                reps = set.reps
+                            )
+                        }
+                    )
+                }
+                if (session.distanceLogs.isNotEmpty()) {
+                    draftSessionDao.insertDistanceLogs(
+                        session.distanceLogs.map { log ->
+                            DraftDistanceEntity(
+                                workoutId = session.workoutId,
+                                workoutExerciseId = log.workoutExerciseId,
+                                amount = log.amount,
+                                unit = log.unit,
+                                hours = log.hours,
+                                minutes = log.minutes,
+                                seconds = log.seconds
+                            )
+                        }
+                    )
+                }
+            }
+        }
+    }
+
+    suspend fun saveWorkout(name: String, exercises: List<DraftExercise>): String {
+        return database.withTransaction {
+            val workoutId = UuidV7.generateString()
+            workoutDao.insertWorkout(
+                WorkoutEntity(
+                    id = workoutId,
+                    name = name.trim()
+                )
+            )
+            if (exercises.isNotEmpty()) {
+                workoutDao.insertExercises(
+                    exercises.mapIndexed { index, exercise ->
+                        WorkoutExerciseEntity(
+                            id = exercise.id,
+                            workoutId = workoutId,
+                            name = exercise.name.trim(),
+                            type = exercise.type.storageValue,
+                            sortOrder = index,
+                            isActive = exercise.isActive,
+                            notes = ""
+                        )
+                    }
+                )
+            }
+            workoutId
+        }
+    }
+
+    suspend fun updateWorkout(
+        id: String,
+        name: String,
+        exercises: List<DraftExercise>
+    ) {
+        database.withTransaction {
+            workoutDao.updateWorkoutName(id, name.trim())
+            workoutDao.deleteExercisesForWorkout(id)
+            if (exercises.isNotEmpty()) {
+                workoutDao.insertExercises(
+                    exercises.mapIndexed { index, exercise ->
+                        WorkoutExerciseEntity(
+                            id = exercise.id,
+                            workoutId = id,
+                            name = exercise.name.trim(),
+                            type = exercise.type.storageValue,
+                            sortOrder = index,
+                            isActive = exercise.isActive,
+                            notes = exercise.notes
+                        )
+                    }
+                )
+            }
+        }
+    }
+
+    suspend fun completeSession(
+        workoutId: String,
+        startedAt: Long,
+        finishedAt: Long,
+        exercises: List<SessionExerciseInput>
+    ): String {
+        return draftMutex.withLock {
+            discardedDrafts += workoutId
+            database.withTransaction {
+            draftSessionDao.deleteSession(workoutId)
+            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,
+                    distanceMeters = exercise.distanceMeters,
+                    durationSeconds = exercise.durationSeconds
+                )
+            }
+            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
+            }
+        }
+    }
+}

+ 37 - 0
app/src/main/java/com/example/torus/id/UuidV7.kt

@@ -0,0 +1,37 @@
+package com.example.torus.id
+
+import java.security.SecureRandom
+import java.util.UUID
+
+object UuidV7 {
+    private val random = SecureRandom()
+    private val lock = Any()
+    private var lastMillis = -1L
+    private var lastRandA = 0
+
+    fun generate(): UUID {
+        synchronized(lock) {
+            var millis = System.currentTimeMillis()
+            var randA = random.nextInt(1 shl 12)
+
+            if (millis <= lastMillis) {
+                millis = lastMillis
+                randA = (lastRandA + 1) and 0xFFF
+                if (randA == 0) {
+                    millis += 1
+                }
+            }
+
+            lastMillis = millis
+            lastRandA = randA
+
+            val mostSigBits = ((millis and 0xFFFFFFFFFFFFL) shl 16) or
+                (0x7L shl 12) or
+                randA.toLong()
+            val leastSigBits = (random.nextLong() and 0x3FFFFFFFFFFFFFFFL) or Long.MIN_VALUE
+            return UUID(mostSigBits, leastSigBits)
+        }
+    }
+
+    fun generateString(): String = generate().toString()
+}

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

@@ -0,0 +1,37 @@
+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
+}
+
+fun formatAmountFromMeters(meters: Double, unit: DistanceUnit): String {
+    val amount = meters / unit.metersPerUnit
+    val rounded = kotlin.math.round(amount * 1_000_000.0) / 1_000_000.0
+    return if (rounded % 1.0 == 0.0) {
+        rounded.toLong().toString()
+    } else {
+        String.format(java.util.Locale.US, "%.6f", rounded).trimEnd('0').trimEnd('.')
+    }
+}
+
+fun durationHours(totalSeconds: Int): Int = totalSeconds / 3600
+
+fun durationMinutes(totalSeconds: Int): Int = (totalSeconds % 3600) / 60
+
+fun durationSecondsPart(totalSeconds: Int): Int = totalSeconds % 60

+ 9 - 0
app/src/main/java/com/example/torus/model/DraftExercise.kt

@@ -0,0 +1,9 @@
+package com.example.torus.model
+
+data class DraftExercise(
+    val id: String,
+    val type: ExerciseType,
+    val name: String = "",
+    val notes: String = "",
+    val isActive: Boolean = true,
+)

+ 15 - 0
app/src/main/java/com/example/torus/model/ExerciseType.kt

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

+ 26 - 0
app/src/main/java/com/example/torus/model/InProgressSession.kt

@@ -0,0 +1,26 @@
+package com.example.torus.model
+
+data class InProgressSession(
+    val workoutId: String,
+    val startedAt: Long,
+    val currentExerciseIndex: Int,
+    val weightSets: List<InProgressWeightSet>,
+    val distanceLogs: List<InProgressDistanceLog>,
+)
+
+data class InProgressWeightSet(
+    val id: String,
+    val workoutExerciseId: String,
+    val sortOrder: Int,
+    val weight: String,
+    val reps: String,
+)
+
+data class InProgressDistanceLog(
+    val workoutExerciseId: String,
+    val amount: String,
+    val unit: String,
+    val hours: String,
+    val minutes: String,
+    val seconds: String,
+)

+ 41 - 0
app/src/main/java/com/example/torus/model/LastExerciseLog.kt

@@ -0,0 +1,41 @@
+package com.example.torus.model
+
+data class LastExerciseLog(
+    val type: ExerciseType,
+    val sets: List<LastWeightSet> = emptyList(),
+    val distanceMeters: Double = 0.0,
+    val durationSeconds: Int = 0,
+)
+
+data class LastWeightSet(
+    val weight: String,
+    val reps: String,
+)
+
+fun LastExerciseLog.hasRecordedData(): Boolean {
+    return when (type) {
+        ExerciseType.Weights -> sets.any { set ->
+            set.weight.isNotBlank() || set.reps.isNotBlank()
+        }
+        ExerciseType.Distance -> distanceMeters > 0.0 || durationSeconds > 0
+    }
+}
+
+fun formatDistanceMeters(meters: Double): String {
+    return if (meters >= 1000.0) {
+        String.format(java.util.Locale.US, "%.2f km", meters / 1000.0)
+    } else {
+        String.format(java.util.Locale.US, "%.1f m", meters)
+    }
+}
+
+fun formatDurationSeconds(totalSeconds: Int): String {
+    val hours = totalSeconds / 3600
+    val minutes = (totalSeconds % 3600) / 60
+    val seconds = totalSeconds % 60
+    return if (hours > 0) {
+        String.format(java.util.Locale.US, "%d:%02d:%02d", hours, minutes, seconds)
+    } else {
+        String.format(java.util.Locale.US, "%d:%02d", minutes, seconds)
+    }
+}

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

@@ -0,0 +1,15 @@
+package com.example.torus.model
+
+data class SessionExerciseInput(
+    val workoutExerciseId: String,
+    val name: String,
+    val type: ExerciseType,
+    val sets: List<SessionSetInput> = emptyList(),
+    val distanceMeters: Double = 0.0,
+    val durationSeconds: Int = 0,
+)
+
+data class SessionSetInput(
+    val weight: String,
+    val reps: String,
+)

+ 32 - 0
app/src/main/java/com/example/torus/model/Workout.kt

@@ -0,0 +1,32 @@
+package com.example.torus.model
+
+
+data class WorkoutSummary(
+    val id: String,
+    val name: String,
+)
+
+data class WorkoutDetail(
+    val id: String,
+    val name: String,
+    val exercises: List<SavedExercise>,
+)
+
+data class SavedExercise(
+    val id: String,
+    val name: String,
+    val type: ExerciseType,
+    val sortOrder: Int,
+    val isActive: Boolean,
+    val notes: String,
+) {
+    fun toDraft(): DraftExercise {
+        return DraftExercise(
+            id = id,
+            type = type,
+            name = name,
+            notes = notes,
+            isActive = isActive
+        )
+    }
+}

+ 56 - 0
app/src/main/java/com/example/torus/ui/AppHeader.kt

@@ -0,0 +1,56 @@
+package com.example.torus.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.example.torus.R
+
+@Composable
+fun AppHeader(
+    modifier: Modifier = Modifier,
+    onClick: () -> Unit = {}
+) {
+    Row(
+        modifier = modifier
+            .fillMaxWidth()
+            .clickable(onClick = onClick)
+            .padding(horizontal = 16.dp, vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.Start
+    ) {
+        Image(
+            painter = painterResource(R.drawable.torus),
+            contentDescription = stringResource(R.string.logo_content_description),
+            modifier = Modifier
+                .size(32.dp)
+                .clip(CircleShape),
+            contentScale = ContentScale.Crop
+        )
+        Spacer(modifier = Modifier.width(10.dp))
+        Text(
+            text = stringResource(R.string.app_name),
+            color = MaterialTheme.colorScheme.onBackground,
+            style = MaterialTheme.typography.titleLarge.copy(
+                fontWeight = FontWeight.SemiBold
+            )
+        )
+    }
+}

+ 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
+    )
+}

+ 158 - 0
app/src/main/java/com/example/torus/ui/CreateWorkoutScreen.kt

@@ -0,0 +1,158 @@
+package com.example.torus.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+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
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateListOf
+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.runtime.withFrameNanos
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.example.torus.R
+import com.example.torus.id.UuidV7
+import com.example.torus.model.DraftExercise
+import com.example.torus.model.ExerciseType
+import com.example.torus.ui.theme.Sand
+import com.example.torus.ui.theme.TorusTheme
+import kotlinx.coroutines.launch
+
+@Composable
+fun CreateWorkoutScreen(
+    modifier: Modifier = Modifier,
+    onSaveWorkout: (workoutName: String, exercises: List<DraftExercise>) -> Unit = { _, _ -> },
+    onCancel: () -> Unit = {}
+) {
+    var workoutName by rememberSaveable { mutableStateOf("") }
+    var showExerciseTypes by rememberSaveable { mutableStateOf(false) }
+    var isSaving by remember { mutableStateOf(false) }
+    val exercises = remember { mutableStateListOf<DraftExercise>() }
+    val scrollState = rememberScrollState()
+    val coroutineScope = rememberCoroutineScope()
+
+    fun addExercise(type: ExerciseType) {
+        exercises += DraftExercise(id = UuidV7.generateString(), type = type)
+        showExerciseTypes = false
+    }
+
+    Column(modifier = modifier.fillMaxSize()) {
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .fillMaxWidth()
+                .verticalScroll(scrollState)
+                .padding(horizontal = 16.dp, vertical = 8.dp),
+            verticalArrangement = Arrangement.spacedBy(16.dp)
+        ) {
+            Text(
+                text = stringResource(R.string.create_workout),
+                color = Sand,
+                style = MaterialTheme.typography.headlineMedium
+            )
+            TorusTextField(
+                value = workoutName,
+                onValueChange = { workoutName = it },
+                label = stringResource(R.string.workout_name)
+            )
+            exercises.forEachIndexed { index, exercise ->
+                key(exercise.id) {
+                    NamedExerciseEditor(
+                        number = index + 1,
+                        typeLabel = stringResource(exercise.type.labelRes),
+                        name = exercise.name,
+                        onNameChange = { name ->
+                            updateExercise(exercises, exercise.id) { it.copy(name = name) }
+                        }
+                    )
+                }
+            }
+            Button(
+                onClick = {
+                    showExerciseTypes = true
+                    coroutineScope.launch {
+                        withFrameNanos { }
+                        withFrameNanos { }
+                        scrollState.animateScrollTo(scrollState.maxValue)
+                    }
+                },
+                shape = RoundedCornerShape(50),
+                colors = ButtonDefaults.buttonColors(
+                    containerColor = MaterialTheme.colorScheme.primary,
+                    contentColor = MaterialTheme.colorScheme.onPrimary
+                )
+            ) {
+                Text(text = stringResource(R.string.add_exercise))
+            }
+            if (showExerciseTypes) {
+                ExerciseTypePicker(
+                    onWeightsClick = { addExercise(ExerciseType.Weights) },
+                    onDistanceClick = { addExercise(ExerciseType.Distance) }
+                )
+            }
+        }
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp, vertical = 12.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            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))
+            }
+        }
+    }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF0C0F14)
+@Composable
+private fun CreateWorkoutScreenPreview() {
+    TorusTheme {
+        Column {
+            AppHeader()
+            CreateWorkoutScreen()
+        }
+    }
+}

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

@@ -0,0 +1,156 @@
+package com.example.torus.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+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.DraftExercise
+import com.example.torus.model.ExerciseType
+import com.example.torus.ui.theme.Sand
+import kotlinx.coroutines.launch
+
+@Composable
+fun EditWorkoutScreen(
+    workoutId: String,
+    workoutRepository: WorkoutRepository,
+    modifier: Modifier = Modifier,
+    onSaveWorkout: (workoutName: String, exercises: List<DraftExercise>) -> Unit = { _, _ -> }
+) {
+    var workoutName by remember { mutableStateOf("") }
+    var showExerciseTypes by remember { mutableStateOf(false) }
+    var isSaving by remember { mutableStateOf(false) }
+    var loaded by remember { mutableStateOf(false) }
+    val exercises = remember { mutableStateListOf<DraftExercise>() }
+    val scrollState = rememberScrollState()
+    val coroutineScope = rememberCoroutineScope()
+
+    LaunchedEffect(workoutId) {
+        val detail = workoutRepository.getWorkoutDetail(workoutId) ?: return@LaunchedEffect
+        workoutName = detail.name
+        exercises.clear()
+        exercises.addAll(detail.exercises.map { it.toDraft() })
+        loaded = true
+    }
+
+    fun addExercise(type: ExerciseType) {
+        exercises += DraftExercise(id = UuidV7.generateString(), type = type)
+        showExerciseTypes = false
+    }
+
+    if (!loaded) {
+        return
+    }
+
+    Column(modifier = modifier.fillMaxSize()) {
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .fillMaxWidth()
+                .verticalScroll(scrollState)
+                .padding(horizontal = 16.dp, vertical = 8.dp),
+            verticalArrangement = Arrangement.spacedBy(16.dp)
+        ) {
+            Text(
+                text = stringResource(R.string.edit_workout),
+                color = Sand,
+                style = MaterialTheme.typography.headlineMedium
+            )
+            TorusTextField(
+                value = workoutName,
+                onValueChange = { workoutName = it },
+                label = stringResource(R.string.workout_name)
+            )
+            exercises.forEachIndexed { index, exercise ->
+                key(exercise.id) {
+                    NamedExerciseEditor(
+                        number = index + 1,
+                        typeLabel = stringResource(exercise.type.labelRes),
+                        name = exercise.name,
+                        onNameChange = { name ->
+                            updateExercise(exercises, exercise.id) { it.copy(name = name) }
+                        },
+                        onMoveUp = if (index > 0) {
+                            { moveExercise(exercises, exercise.id, -1) }
+                        } else {
+                            null
+                        },
+                        onMoveDown = if (index < exercises.lastIndex) {
+                            { moveExercise(exercises, exercise.id, 1) }
+                        } else {
+                            null
+                        },
+                        onRemove = {
+                            exercises.removeAll { it.id == exercise.id }
+                        }
+                    )
+                }
+            }
+            Button(
+                onClick = {
+                    showExerciseTypes = true
+                    coroutineScope.launch {
+                        withFrameNanos { }
+                        withFrameNanos { }
+                        scrollState.animateScrollTo(scrollState.maxValue)
+                    }
+                },
+                shape = RoundedCornerShape(50),
+                colors = ButtonDefaults.buttonColors(
+                    containerColor = MaterialTheme.colorScheme.primary,
+                    contentColor = MaterialTheme.colorScheme.onPrimary
+                )
+            ) {
+                Text(text = stringResource(R.string.add_exercise))
+            }
+            if (showExerciseTypes) {
+                ExerciseTypePicker(
+                    onWeightsClick = { addExercise(ExerciseType.Weights) },
+                    onDistanceClick = { addExercise(ExerciseType.Distance) }
+                )
+            }
+        }
+        Button(
+            onClick = {
+                if (!isSaving) {
+                    isSaving = true
+                    onSaveWorkout(workoutName, exercises.toList())
+                }
+            },
+            enabled = !isSaving,
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp, vertical = 12.dp),
+            shape = RoundedCornerShape(50),
+            colors = ButtonDefaults.buttonColors(
+                containerColor = MaterialTheme.colorScheme.primary,
+                contentColor = MaterialTheme.colorScheme.onPrimary
+            )
+        ) {
+            Text(text = stringResource(R.string.save_workout))
+        }
+    }
+}

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

@@ -0,0 +1,10 @@
+package com.example.torus.ui
+
+import com.example.torus.R
+import com.example.torus.model.ExerciseType
+
+val ExerciseType.labelRes: Int
+    get() = when (this) {
+        ExerciseType.Weights -> R.string.exercise_type_weights
+        ExerciseType.Distance -> R.string.exercise_type_distance
+    }

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

@@ -0,0 +1,86 @@
+package com.example.torus.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.example.torus.R
+import com.example.torus.model.WorkoutSummary
+import com.example.torus.ui.theme.TorusTheme
+
+@Composable
+fun HomeScreen(
+    workouts: List<WorkoutSummary>,
+    modifier: Modifier = Modifier,
+    onAddWorkout: () -> Unit = {},
+    onWorkoutClick: (String) -> Unit = {}
+) {
+    Column(modifier = modifier.fillMaxSize()) {
+        Button(
+            onClick = onAddWorkout,
+            modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+            shape = RoundedCornerShape(50),
+            colors = ButtonDefaults.buttonColors(
+                containerColor = MaterialTheme.colorScheme.primary,
+                contentColor = MaterialTheme.colorScheme.onPrimary
+            )
+        ) {
+            Text(text = stringResource(R.string.add_workout))
+        }
+        LazyColumn(
+            modifier = Modifier
+                .weight(1f)
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp),
+            verticalArrangement = Arrangement.spacedBy(12.dp)
+        ) {
+            items(workouts, key = { it.id }) { workout ->
+                OutlinedButton(
+                    onClick = { onWorkoutClick(workout.id) },
+                    modifier = Modifier.fillMaxWidth(),
+                    shape = RoundedCornerShape(50),
+                    colors = ButtonDefaults.outlinedButtonColors(
+                        contentColor = MaterialTheme.colorScheme.onBackground
+                    )
+                ) {
+                    Text(text = workout.displayName())
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun WorkoutSummary.displayName(): String {
+    return name.ifBlank { stringResource(R.string.untitled_workout) }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF0C0F14)
+@Composable
+private fun HomeScreenPreview() {
+    TorusTheme {
+        Column {
+            AppHeader()
+            HomeScreen(
+                workouts = listOf(
+                    WorkoutSummary(id = "1", name = "Push day"),
+                    WorkoutSummary(id = "2", name = "Easy run")
+                )
+            )
+        }
+    }
+}

+ 970 - 0
app/src/main/java/com/example/torus/ui/StartWorkoutScreen.kt

@@ -0,0 +1,970 @@
+package com.example.torus.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+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
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+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.InProgressDistanceLog
+import com.example.torus.model.InProgressSession
+import com.example.torus.model.InProgressWeightSet
+import com.example.torus.model.LastExerciseLog
+import com.example.torus.model.durationHours
+import com.example.torus.model.durationMinutes
+import com.example.torus.model.durationSecondsPart
+import com.example.torus.model.formatAmountFromMeters
+import com.example.torus.model.formatDistanceMeters
+import com.example.torus.model.formatDurationSeconds
+import com.example.torus.model.hasRecordedData
+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
+import com.example.torus.model.WorkoutDetail
+import com.example.torus.ui.theme.Ok
+import com.example.torus.ui.theme.Sand
+import com.example.torus.ui.theme.TorusTheme
+import com.example.torus.ui.theme.Warn
+
+private data class WeightSetDraft(
+    val id: String,
+    val weight: String = "",
+    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,
+    workoutRepository: WorkoutRepository,
+    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) }
+    var loaded by remember(workoutId) { mutableStateOf(false) }
+    var lastLogs by remember(workoutId) { mutableStateOf<Map<String, LastExerciseLog>>(emptyMap()) }
+    var startedAt by remember(workoutId) { mutableLongStateOf(0L) }
+    val setsByExercise = remember(workoutId) {
+        mutableStateMapOf<String, SnapshotStateList<WeightSetDraft>>()
+    }
+    val distanceByExercise = remember(workoutId) {
+        mutableStateMapOf<String, DistanceDraft>()
+    }
+
+    LaunchedEffect(workoutId) {
+        loaded = false
+        workoutRepository.allowInProgressSaves(workoutId)
+        val workout = workoutRepository.getWorkoutDetail(workoutId)
+        val draft = workoutRepository.getInProgressSession(workoutId)
+        detail = workout
+        setsByExercise.clear()
+        distanceByExercise.clear()
+        restoreInProgress(
+            workout = workout,
+            draft = draft,
+            setsByExercise = setsByExercise,
+            distanceByExercise = distanceByExercise,
+            onIndex = { exerciseIndex = it },
+            onStartedAt = { startedAt = it }
+        )
+        lastLogs = workoutRepository.getLastExerciseLogs(workoutId)
+        loaded = true
+    }
+
+    LaunchedEffect(workoutId, loaded, isCompleting) {
+        if (!loaded || isCompleting) {
+            return@LaunchedEffect
+        }
+        snapshotFlow {
+            captureInProgress(
+                workoutId = workoutId,
+                startedAt = startedAt,
+                exerciseIndex = exerciseIndex,
+                setsByExercise = setsByExercise,
+                distanceByExercise = distanceByExercise
+            )
+        }.collect { session ->
+            workoutRepository.saveInProgressSession(session)
+        }
+    }
+
+    val workout = detail ?: return
+    if (!loaded) {
+        return
+    }
+    StartWorkoutContent(
+        workout = workout,
+        exerciseIndex = exerciseIndex,
+        setsByExercise = setsByExercise,
+        distanceByExercise = distanceByExercise,
+        lastLogs = lastLogs,
+        isCompleting = isCompleting,
+        modifier = modifier,
+        onNextExercise = { exerciseIndex += 1 },
+        onJumpToExercise = { exerciseIndex = it },
+        onCompleteWorkout = {
+            if (!isCompleting) {
+                isCompleting = true
+                onCompleteWorkout(
+                    startedAt,
+                    workout.toSessionInput(setsByExercise, distanceByExercise)
+                )
+            }
+        }
+    )
+}
+
+@Composable
+private fun StartWorkoutContent(
+    workout: WorkoutDetail,
+    exerciseIndex: Int,
+    setsByExercise: MutableMap<String, SnapshotStateList<WeightSetDraft>>,
+    distanceByExercise: MutableMap<String, DistanceDraft>,
+    lastLogs: Map<String, LastExerciseLog> = emptyMap(),
+    isCompleting: Boolean = false,
+    modifier: Modifier = Modifier,
+    onNextExercise: () -> Unit = {},
+    onJumpToExercise: (Int) -> Unit = {},
+    onCompleteWorkout: () -> Unit = {}
+) {
+    var showExerciseMenu by remember { mutableStateOf(false) }
+    val exercises = workout.exercises
+    Column(modifier = modifier.fillMaxSize()) {
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState())
+                .padding(horizontal = 16.dp, vertical = 8.dp),
+            verticalArrangement = Arrangement.spacedBy(16.dp)
+        ) {
+            SessionHeader(
+                workoutName = workout.name.ifBlank { stringResource(R.string.untitled_workout) },
+                showExercisesMenu = exercises.isNotEmpty(),
+                onOpenExercisesMenu = { showExerciseMenu = true }
+            )
+            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]
+                SessionExerciseIdentity(
+                    name = exercise.name.ifBlank { stringResource(R.string.untitled_exercise) },
+                    typeLabel = stringResource(exercise.type.labelRes),
+                    progress = stringResource(
+                        R.string.exercise_progress,
+                        currentIndex + 1,
+                        exercises.size
+                    )
+                )
+                lastLogs[exercise.id]?.takeIf { it.hasRecordedData() }?.let { log ->
+                    LastTimeCard(
+                        log = log,
+                        onUseLastTime = {
+                            when (log.type) {
+                                ExerciseType.Weights -> {
+                                    val sets = setsByExercise.getOrPut(exercise.id) {
+                                        defaultWeightSets()
+                                    }
+                                    sets.clear()
+                                    if (log.sets.isEmpty()) {
+                                        sets.addAll(defaultWeightSets())
+                                    } else {
+                                        log.sets.forEach { lastSet ->
+                                            sets += WeightSetDraft(
+                                                id = UuidV7.generateString(),
+                                                weight = lastSet.weight,
+                                                reps = lastSet.reps
+                                            )
+                                        }
+                                    }
+                                }
+                                ExerciseType.Distance -> {
+                                    val current = distanceByExercise[exercise.id] ?: DistanceDraft()
+                                    distanceByExercise[exercise.id] = current.copy(
+                                        amount = formatAmountFromMeters(
+                                            log.distanceMeters,
+                                            current.unit
+                                        ),
+                                        hours = durationHours(log.durationSeconds).toString(),
+                                        minutes = durationMinutes(log.durationSeconds).toString(),
+                                        seconds = durationSecondsPart(log.durationSeconds).toString()
+                                    )
+                                }
+                            }
+                        }
+                    )
+                }
+                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) }
+                            }
+                        )
+                    }
+                    ExerciseType.Distance -> {
+                        val draft = distanceByExercise.getOrPut(exercise.id) { DistanceDraft() }
+                        DistanceInputs(
+                            draft = draft,
+                            onChange = { updated ->
+                                distanceByExercise[exercise.id] = updated
+                            }
+                        )
+                    }
+                }
+            }
+        }
+        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 = 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.complete_workout))
+            }
+        }
+    }
+    if (showExerciseMenu && exercises.isNotEmpty()) {
+        ExerciseJumpMenu(
+            exercises = exercises,
+            currentIndex = exerciseIndex.coerceIn(0, exercises.lastIndex),
+            setsByExercise = setsByExercise,
+            distanceByExercise = distanceByExercise,
+            onSelect = { index ->
+                onJumpToExercise(index)
+                showExerciseMenu = false
+            },
+            onDismiss = { showExerciseMenu = false }
+        )
+    }
+}
+
+@Composable
+private fun SessionHeader(
+    workoutName: String,
+    showExercisesMenu: Boolean,
+    onOpenExercisesMenu: () -> Unit
+) {
+    Row(
+        modifier = Modifier.fillMaxWidth(),
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Text(
+            text = workoutName,
+            modifier = Modifier.weight(1f),
+            color = Sand,
+            style = MaterialTheme.typography.titleLarge,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis
+        )
+        if (showExercisesMenu) {
+            OutlinedButton(
+                onClick = onOpenExercisesMenu,
+                shape = RoundedCornerShape(50),
+                contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp),
+                colors = ButtonDefaults.outlinedButtonColors(
+                    contentColor = MaterialTheme.colorScheme.onBackground
+                ),
+                border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
+            ) {
+                Text(
+                    text = stringResource(R.string.exercises_menu),
+                    style = MaterialTheme.typography.labelLarge
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun SessionExerciseIdentity(
+    name: String,
+    typeLabel: String,
+    progress: String
+) {
+    Row(
+        modifier = Modifier.fillMaxWidth(),
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Text(
+            text = "$name  ·  $typeLabel",
+            modifier = Modifier.weight(1f),
+            color = MaterialTheme.colorScheme.onBackground,
+            style = MaterialTheme.typography.titleMedium,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis
+        )
+        Text(
+            text = progress,
+            color = MaterialTheme.colorScheme.onSurfaceVariant,
+            style = MaterialTheme.typography.labelLarge,
+            maxLines = 1
+        )
+    }
+}
+
+@Composable
+private fun LastTimeCard(
+    log: LastExerciseLog,
+    onUseLastTime: () -> Unit
+) {
+    val shape = RoundedCornerShape(14.dp)
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .background(MaterialTheme.colorScheme.surfaceVariant, shape)
+            .border(1.dp, MaterialTheme.colorScheme.outline, shape)
+            .padding(16.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp)
+    ) {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Text(
+                text = stringResource(R.string.last_time),
+                color = Sand,
+                style = MaterialTheme.typography.titleSmall
+            )
+            OutlinedButton(
+                onClick = onUseLastTime,
+                shape = RoundedCornerShape(50),
+                contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp),
+                colors = ButtonDefaults.outlinedButtonColors(
+                    contentColor = MaterialTheme.colorScheme.onBackground
+                ),
+                border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
+            ) {
+                Text(
+                    text = stringResource(R.string.autofill),
+                    style = MaterialTheme.typography.labelLarge
+                )
+            }
+        }
+        when (log.type) {
+            ExerciseType.Weights -> {
+                if (log.sets.isEmpty()) {
+                    Text(
+                        text = stringResource(R.string.last_empty_value),
+                        color = MaterialTheme.colorScheme.onSurfaceVariant,
+                        style = MaterialTheme.typography.bodyMedium
+                    )
+                } else {
+                    log.sets.forEachIndexed { index, set ->
+                        val weight = set.weight.ifBlank { stringResource(R.string.last_empty_value) }
+                        val reps = set.reps.ifBlank { stringResource(R.string.last_empty_value) }
+                        Text(
+                            text = stringResource(R.string.last_set, index + 1, weight, reps),
+                            color = MaterialTheme.colorScheme.onBackground,
+                            style = MaterialTheme.typography.bodyMedium
+                        )
+                    }
+                }
+            }
+            ExerciseType.Distance -> {
+                Text(
+                    text = stringResource(
+                        R.string.last_distance,
+                        formatDistanceMeters(log.distanceMeters)
+                    ),
+                    color = MaterialTheme.colorScheme.onBackground,
+                    style = MaterialTheme.typography.bodyMedium
+                )
+                Text(
+                    text = stringResource(
+                        R.string.last_duration,
+                        formatDurationSeconds(log.durationSeconds)
+                    ),
+                    color = MaterialTheme.colorScheme.onBackground,
+                    style = MaterialTheme.typography.bodyMedium
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun WeightSetsEditor(
+    sets: SnapshotStateList<WeightSetDraft>,
+    onAddSet: () -> Unit,
+    onRemoveSet: (String) -> Unit,
+    onWeightChange: (String, String) -> Unit,
+    onRepsChange: (String, String) -> Unit
+) {
+    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+        sets.forEachIndexed { index, set ->
+            key(set.id) {
+                WeightSetCard(
+                    number = index + 1,
+                    set = set,
+                    canRemove = sets.size > 1,
+                    onWeightChange = { onWeightChange(set.id, it) },
+                    onRepsChange = { onRepsChange(set.id, it) },
+                    onRemove = { onRemoveSet(set.id) }
+                )
+            }
+        }
+        OutlinedButton(
+            onClick = onAddSet,
+            modifier = Modifier.fillMaxWidth(),
+            shape = RoundedCornerShape(50),
+            colors = ButtonDefaults.outlinedButtonColors(
+                contentColor = MaterialTheme.colorScheme.onBackground
+            )
+        ) {
+            Text(text = stringResource(R.string.add_set))
+        }
+    }
+}
+
+@Composable
+private fun WeightSetCard(
+    number: Int,
+    set: WeightSetDraft,
+    canRemove: Boolean,
+    onWeightChange: (String) -> Unit,
+    onRepsChange: (String) -> Unit,
+    onRemove: () -> Unit
+) {
+    val shape = RoundedCornerShape(14.dp)
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .border(1.dp, MaterialTheme.colorScheme.outline, shape)
+            .padding(16.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp)
+    ) {
+        Text(
+            text = stringResource(R.string.set_number, number),
+            color = MaterialTheme.colorScheme.onSurfaceVariant,
+            style = MaterialTheme.typography.labelLarge
+        )
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+            verticalAlignment = Alignment.Top
+        ) {
+            TorusTextField(
+                value = set.weight,
+                onValueChange = { onWeightChange(filterDecimal(it)) },
+                label = stringResource(R.string.weight),
+                modifier = Modifier.weight(1f),
+                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal)
+            )
+            TorusTextField(
+                value = set.reps,
+                onValueChange = { onRepsChange(it.filter(Char::isDigit)) },
+                label = stringResource(R.string.reps),
+                modifier = Modifier.weight(1f),
+                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+            )
+        }
+        if (canRemove) {
+            TextButton(onClick = onRemove) {
+                Text(
+                    text = stringResource(R.string.remove_set),
+                    color = MaterialTheme.colorScheme.error
+                )
+            }
+        }
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ExerciseJumpMenu(
+    exercises: List<SavedExercise>,
+    currentIndex: Int,
+    setsByExercise: Map<String, List<WeightSetDraft>>,
+    distanceByExercise: Map<String, DistanceDraft>,
+    onSelect: (Int) -> Unit,
+    onDismiss: () -> Unit
+) {
+    ModalBottomSheet(
+        onDismissRequest = onDismiss,
+        containerColor = MaterialTheme.colorScheme.surfaceVariant,
+        contentColor = MaterialTheme.colorScheme.onBackground
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState())
+                .padding(horizontal = 16.dp)
+                .padding(bottom = 24.dp),
+            verticalArrangement = Arrangement.spacedBy(12.dp)
+        ) {
+            Text(
+                text = stringResource(R.string.exercises_menu),
+                color = MaterialTheme.colorScheme.onSurfaceVariant,
+                style = MaterialTheme.typography.titleSmall
+            )
+            exercises.forEachIndexed { index, exercise ->
+                val selected = index == currentIndex
+                val completed = exercise.isComplete(setsByExercise, distanceByExercise)
+                val shape = RoundedCornerShape(14.dp)
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .background(MaterialTheme.colorScheme.surface, shape)
+                        .border(
+                            width = 1.dp,
+                            color = if (selected) {
+                                MaterialTheme.colorScheme.primary
+                            } else {
+                                MaterialTheme.colorScheme.outline
+                            },
+                            shape = shape
+                        )
+                        .clickable { onSelect(index) }
+                        .padding(16.dp),
+                    horizontalArrangement = Arrangement.SpaceBetween,
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    Column(
+                        modifier = Modifier.weight(1f),
+                        verticalArrangement = Arrangement.spacedBy(4.dp)
+                    ) {
+                        Text(
+                            text = (index + 1).toString(),
+                            color = MaterialTheme.colorScheme.onSurfaceVariant,
+                            style = MaterialTheme.typography.labelSmall
+                        )
+                        Text(
+                            text = exercise.name.ifBlank {
+                                stringResource(R.string.untitled_exercise)
+                            },
+                            color = MaterialTheme.colorScheme.onBackground,
+                            style = MaterialTheme.typography.bodyLarge
+                        )
+                        Text(
+                            text = stringResource(exercise.type.labelRes),
+                            color = MaterialTheme.colorScheme.onSurfaceVariant,
+                            style = MaterialTheme.typography.labelLarge
+                        )
+                    }
+                    Text(
+                        text = stringResource(
+                            if (completed) {
+                                R.string.exercise_complete
+                            } else {
+                                R.string.exercise_incomplete
+                            }
+                        ),
+                        color = if (completed) Ok else Warn,
+                        style = MaterialTheme.typography.labelLarge
+                    )
+                }
+            }
+        }
+    }
+}
+
+@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)
+            )
+        }
+    }
+}
+
+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())
+        }
+    }
+}
+
+private fun restoreInProgress(
+    workout: WorkoutDetail?,
+    draft: InProgressSession?,
+    setsByExercise: MutableMap<String, SnapshotStateList<WeightSetDraft>>,
+    distanceByExercise: MutableMap<String, DistanceDraft>,
+    onIndex: (Int) -> Unit,
+    onStartedAt: (Long) -> Unit
+) {
+    val exercises = workout?.exercises.orEmpty()
+    val setsByDraftId = draft?.weightSets.orEmpty().groupBy { it.workoutExerciseId }
+    val distanceByDraftId = draft?.distanceLogs.orEmpty().associateBy { it.workoutExerciseId }
+    exercises.forEach { exercise ->
+        when (exercise.type) {
+            ExerciseType.Weights -> {
+                val saved = setsByDraftId[exercise.id].orEmpty().sortedBy { it.sortOrder }
+                setsByExercise[exercise.id] = if (saved.isEmpty()) {
+                    defaultWeightSets()
+                } else {
+                    mutableStateListOf<WeightSetDraft>().also { list ->
+                        saved.forEach { set ->
+                            list += WeightSetDraft(
+                                id = set.id,
+                                weight = set.weight,
+                                reps = set.reps
+                            )
+                        }
+                    }
+                }
+            }
+            ExerciseType.Distance -> {
+                val saved = distanceByDraftId[exercise.id]
+                distanceByExercise[exercise.id] = if (saved == null) {
+                    DistanceDraft()
+                } else {
+                    DistanceDraft(
+                        amount = saved.amount,
+                        unit = DistanceUnit.entries.find { it.name == saved.unit }
+                            ?: DistanceUnit.Kilometers,
+                        hours = saved.hours,
+                        minutes = saved.minutes,
+                        seconds = saved.seconds
+                    )
+                }
+            }
+        }
+    }
+    val lastIndex = (exercises.size - 1).coerceAtLeast(0)
+    onIndex((draft?.currentExerciseIndex ?: 0).coerceIn(0, lastIndex))
+    onStartedAt(draft?.startedAt ?: System.currentTimeMillis())
+}
+
+private fun captureInProgress(
+    workoutId: String,
+    startedAt: Long,
+    exerciseIndex: Int,
+    setsByExercise: Map<String, List<WeightSetDraft>>,
+    distanceByExercise: Map<String, DistanceDraft>
+): InProgressSession {
+    return InProgressSession(
+        workoutId = workoutId,
+        startedAt = startedAt,
+        currentExerciseIndex = exerciseIndex,
+        weightSets = setsByExercise.flatMap { (exerciseId, sets) ->
+            sets.mapIndexed { index, set ->
+                InProgressWeightSet(
+                    id = set.id,
+                    workoutExerciseId = exerciseId,
+                    sortOrder = index,
+                    weight = set.weight,
+                    reps = set.reps
+                )
+            }
+        },
+        distanceLogs = distanceByExercise.map { (exerciseId, draft) ->
+            InProgressDistanceLog(
+                workoutExerciseId = exerciseId,
+                amount = draft.amount,
+                unit = draft.unit.name,
+                hours = draft.hours,
+                minutes = draft.minutes,
+                seconds = draft.seconds
+            )
+        }
+    )
+}
+
+private fun defaultWeightSets(): SnapshotStateList<WeightSetDraft> {
+    return mutableStateListOf(
+        WeightSetDraft(id = UuidV7.generateString()),
+        WeightSetDraft(id = UuidV7.generateString()),
+        WeightSetDraft(id = UuidV7.generateString())
+    )
+}
+
+private fun updateSet(
+    sets: SnapshotStateList<WeightSetDraft>,
+    id: String,
+    transform: (WeightSetDraft) -> WeightSetDraft
+) {
+    val index = sets.indexOfFirst { it.id == id }
+    if (index >= 0) {
+        sets[index] = transform(sets[index])
+    }
+}
+
+private fun WorkoutDetail.toSessionInput(
+    setsByExercise: Map<String, List<WeightSetDraft>>,
+    distanceByExercise: Map<String, DistanceDraft>
+): 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()
+        }
+        val distanceLog = distanceByExercise[exercise.id] ?: DistanceDraft()
+        SessionExerciseInput(
+            workoutExerciseId = exercise.id,
+            name = exercise.name,
+            type = exercise.type,
+            sets = sets,
+            distanceMeters = parseDistanceMeters(distanceLog.amount, distanceLog.unit),
+            durationSeconds = parseDurationSeconds(
+                distanceLog.hours,
+                distanceLog.minutes,
+                distanceLog.seconds
+            )
+        )
+    }
+}
+
+private fun filterDecimal(value: String): String {
+    val filtered = value.filter { it.isDigit() || it == '.' }
+    val firstDot = filtered.indexOf('.')
+    if (firstDot < 0) {
+        return filtered
+    }
+    return filtered.take(firstDot + 1) + filtered.substring(firstDot + 1).replace(".", "")
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF0C0F14)
+@Composable
+private fun StartWorkoutContentPreview() {
+    TorusTheme {
+        StartWorkoutContent(
+            workout = WorkoutDetail(
+                id = "1",
+                name = "Push day",
+                exercises = listOf(
+                    SavedExercise(
+                        id = "a",
+                        name = "Bench",
+                        type = ExerciseType.Weights,
+                        sortOrder = 0,
+                        isActive = true,
+                        notes = ""
+                    ),
+                    SavedExercise(
+                        id = "b",
+                        name = "Easy run",
+                        type = ExerciseType.Distance,
+                        sortOrder = 1,
+                        isActive = true,
+                        notes = ""
+                    )
+                )
+            ),
+            exerciseIndex = 0,
+            setsByExercise = mutableStateMapOf(),
+            distanceByExercise = mutableStateMapOf()
+        )
+    }
+}

+ 154 - 0
app/src/main/java/com/example/torus/ui/TorusApp.kt

@@ -0,0 +1,154 @@
+package com.example.torus.ui
+
+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
+
+private object Routes {
+    const val HOME = "home"
+    const val CREATE = "create"
+
+    fun workout(id: String) = "workout/$id"
+
+    fun edit(id: String) = "edit/$id"
+
+    fun isWorkout(route: String) = route.startsWith("workout/")
+
+    fun isEdit(route: String) = route.startsWith("edit/")
+
+    fun start(id: String) = "start/$id"
+
+    fun isStart(route: String) = route.startsWith("start/")
+
+    fun workoutId(route: String) = route.removePrefix("workout/")
+
+    fun editId(route: String) = route.removePrefix("edit/")
+
+    fun startId(route: String) = route.removePrefix("start/")
+}
+
+@Composable
+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) {
+        navigationStack = navigationStack + route
+    }
+
+    fun popBack() {
+        if (navigationStack.size > 1) {
+            navigationStack = navigationStack.dropLast(1)
+        }
+    }
+
+    fun goHome() {
+        navigationStack = listOf(Routes.HOME)
+    }
+
+    BackHandler(enabled = navigationStack.size > 1) {
+        popBack()
+    }
+
+    Scaffold(
+        modifier = Modifier.fillMaxSize(),
+        topBar = { AppHeader(onClick = { goHome() }) },
+        snackbarHost = {
+            SnackbarHost(hostState = snackbarHostState) { data ->
+                ConfirmationBanner(message = data.visuals.message)
+            }
+        }
+    ) { innerPadding ->
+        val contentModifier = Modifier.padding(innerPadding)
+        when {
+            currentRoute == Routes.HOME -> HomeScreen(
+                workouts = workouts,
+                modifier = contentModifier,
+                onAddWorkout = { navigateTo(Routes.CREATE) },
+                onWorkoutClick = { workoutId -> navigateTo(Routes.workout(workoutId)) }
+            )
+            currentRoute == Routes.CREATE -> CreateWorkoutScreen(
+                modifier = contentModifier,
+                onSaveWorkout = { name, exercises ->
+                    coroutineScope.launch {
+                        workoutRepository.saveWorkout(name, exercises)
+                        goHome()
+                    }
+                },
+                onCancel = { popBack() }
+            )
+            Routes.isWorkout(currentRoute) -> {
+                val workoutId = Routes.workoutId(currentRoute)
+                WorkoutDetailScreen(
+                    workoutId = workoutId,
+                    workoutRepository = workoutRepository,
+                    modifier = contentModifier,
+                    onStartWorkout = { navigateTo(Routes.start(workoutId)) },
+                    onResumeWorkout = { navigateTo(Routes.start(workoutId)) },
+                    onStartNewWorkout = {
+                        coroutineScope.launch {
+                            workoutRepository.discardInProgressSession(workoutId)
+                            navigateTo(Routes.start(workoutId))
+                        }
+                    },
+                    onEditWorkout = { navigateTo(Routes.edit(workoutId)) },
+                    onHome = { goHome() }
+                )
+            }
+            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(
+                    workoutId = workoutId,
+                    workoutRepository = workoutRepository,
+                    modifier = contentModifier,
+                    onSaveWorkout = { name, exercises ->
+                        coroutineScope.launch {
+                            workoutRepository.updateWorkout(workoutId, name, exercises)
+                            popBack()
+                        }
+                    }
+                )
+            }
+        }
+    }
+}

+ 161 - 0
app/src/main/java/com/example/torus/ui/WorkoutDetailScreen.kt

@@ -0,0 +1,161 @@
+package com.example.torus.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.example.torus.R
+import com.example.torus.data.WorkoutRepository
+import com.example.torus.model.WorkoutDetail
+import com.example.torus.ui.theme.Sand
+import com.example.torus.ui.theme.TorusTheme
+
+@Composable
+fun WorkoutDetailScreen(
+    workoutId: String,
+    workoutRepository: WorkoutRepository,
+    modifier: Modifier = Modifier,
+    onStartWorkout: () -> Unit = {},
+    onResumeWorkout: () -> Unit = {},
+    onStartNewWorkout: () -> Unit = {},
+    onEditWorkout: () -> Unit = {},
+    onHome: () -> Unit = {}
+) {
+    var detail by remember(workoutId) { mutableStateOf<WorkoutDetail?>(null) }
+    var hasInProgressSession by remember(workoutId) { mutableStateOf(false) }
+
+    LaunchedEffect(workoutId) {
+        detail = workoutRepository.getWorkoutDetail(workoutId)
+        hasInProgressSession = workoutRepository.hasInProgressSession(workoutId)
+    }
+
+    detail?.let { workout ->
+        WorkoutDetailContent(
+            workout = workout,
+            hasInProgressSession = hasInProgressSession,
+            modifier = modifier,
+            onStartWorkout = onStartWorkout,
+            onResumeWorkout = onResumeWorkout,
+            onStartNewWorkout = onStartNewWorkout,
+            onEditWorkout = onEditWorkout,
+            onHome = onHome
+        )
+    }
+}
+
+@Composable
+private fun WorkoutDetailContent(
+    workout: WorkoutDetail,
+    hasInProgressSession: Boolean = false,
+    modifier: Modifier = Modifier,
+    onStartWorkout: () -> Unit = {},
+    onResumeWorkout: () -> Unit = {},
+    onStartNewWorkout: () -> Unit = {},
+    onEditWorkout: () -> Unit = {},
+    onHome: () -> Unit = {}
+) {
+    Column(
+        modifier = modifier
+            .fillMaxSize()
+            .padding(horizontal = 16.dp, vertical = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp)
+    ) {
+        Text(
+            text = workout.name.ifBlank { stringResource(R.string.untitled_workout) },
+            color = Sand,
+            style = MaterialTheme.typography.headlineMedium
+        )
+        if (hasInProgressSession) {
+            EmberButton(
+                text = stringResource(R.string.resume_workout),
+                onClick = onResumeWorkout
+            )
+            GhostButton(
+                text = stringResource(R.string.start_new_workout),
+                onClick = onStartNewWorkout
+            )
+        } else {
+            EmberButton(
+                text = stringResource(R.string.start_workout),
+                onClick = onStartWorkout
+            )
+        }
+        GhostButton(
+            text = stringResource(R.string.edit_workout),
+            onClick = onEditWorkout
+        )
+        GhostButton(
+            text = stringResource(R.string.home),
+            onClick = onHome
+        )
+    }
+}
+
+@Composable
+private fun EmberButton(
+    text: String,
+    onClick: () -> Unit
+) {
+    Button(
+        onClick = onClick,
+        modifier = Modifier.fillMaxWidth(),
+        shape = RoundedCornerShape(50),
+        colors = ButtonDefaults.buttonColors(
+            containerColor = MaterialTheme.colorScheme.primary,
+            contentColor = MaterialTheme.colorScheme.onPrimary
+        )
+    ) {
+        Text(text = text)
+    }
+}
+
+@Composable
+private fun GhostButton(
+    text: String,
+    onClick: () -> Unit
+) {
+    OutlinedButton(
+        onClick = onClick,
+        modifier = Modifier.fillMaxWidth(),
+        shape = RoundedCornerShape(50),
+        colors = ButtonDefaults.outlinedButtonColors(
+            contentColor = MaterialTheme.colorScheme.onBackground
+        )
+    ) {
+        Text(text = text)
+    }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF0C0F14)
+@Composable
+private fun WorkoutDetailContentPreview() {
+    TorusTheme {
+        Column {
+            AppHeader()
+            WorkoutDetailContent(
+                workout = WorkoutDetail(
+                    id = "1",
+                    name = "Push day",
+                    exercises = emptyList()
+                )
+            )
+        }
+    }
+}

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

@@ -0,0 +1,200 @@
+package com.example.torus.ui
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextFieldColors
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.example.torus.R
+import com.example.torus.model.DraftExercise
+
+fun updateExercise(
+    exercises: MutableList<DraftExercise>,
+    id: String,
+    transform: (DraftExercise) -> DraftExercise
+) {
+    val index = exercises.indexOfFirst { it.id == id }
+    if (index >= 0) {
+        exercises[index] = transform(exercises[index])
+    }
+}
+
+fun moveExercise(exercises: MutableList<DraftExercise>, id: String, offset: Int) {
+    val index = exercises.indexOfFirst { it.id == id }
+    val target = index + offset
+    if (index < 0 || target !in exercises.indices) {
+        return
+    }
+    val exercise = exercises.removeAt(index)
+    exercises.add(target, exercise)
+}
+
+@Composable
+fun NamedExerciseEditor(
+    number: Int,
+    typeLabel: String,
+    name: String,
+    onNameChange: (String) -> Unit,
+    modifier: Modifier = Modifier,
+    onMoveUp: (() -> Unit)? = null,
+    onMoveDown: (() -> Unit)? = null,
+    onRemove: (() -> Unit)? = null
+) {
+    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.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Text(
+                text = typeLabel,
+                color = MaterialTheme.colorScheme.onSurfaceVariant,
+                style = MaterialTheme.typography.labelLarge
+            )
+            Text(
+                text = number.toString(),
+                color = MaterialTheme.colorScheme.onSurfaceVariant,
+                style = MaterialTheme.typography.labelSmall
+            )
+        }
+        TorusTextField(
+            value = name,
+            onValueChange = onNameChange,
+            label = stringResource(R.string.exercise_name)
+        )
+        if (onMoveUp != null || onMoveDown != null || onRemove != null) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                if (onMoveUp != null) {
+                    TextButton(onClick = onMoveUp) {
+                        Text(text = stringResource(R.string.move_up))
+                    }
+                }
+                if (onMoveDown != null) {
+                    TextButton(onClick = onMoveDown) {
+                        Text(text = stringResource(R.string.move_down))
+                    }
+                }
+                if (onRemove != null) {
+                    TextButton(onClick = onRemove) {
+                        Text(
+                            text = stringResource(R.string.remove_exercise),
+                            color = MaterialTheme.colorScheme.error
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun ExerciseTypePicker(
+    onWeightsClick: () -> Unit,
+    onDistanceClick: () -> Unit = {}
+) {
+    val shape = RoundedCornerShape(14.dp)
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .background(MaterialTheme.colorScheme.surfaceVariant, shape)
+            .border(1.dp, MaterialTheme.colorScheme.primary, shape)
+            .padding(16.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp)
+    ) {
+        Text(
+            text = stringResource(R.string.choose_exercise_type),
+            color = MaterialTheme.colorScheme.onSurfaceVariant,
+            style = MaterialTheme.typography.titleSmall
+        )
+        ExerciseTypeOption(
+            label = stringResource(R.string.exercise_type_weights),
+            onClick = onWeightsClick
+        )
+        ExerciseTypeOption(
+            label = stringResource(R.string.exercise_type_distance),
+            onClick = onDistanceClick
+        )
+    }
+}
+
+@Composable
+private fun ExerciseTypeOption(
+    label: String,
+    onClick: () -> Unit = {}
+) {
+    OutlinedButton(
+        onClick = onClick,
+        modifier = Modifier.fillMaxWidth(),
+        shape = RoundedCornerShape(50),
+        colors = ButtonDefaults.outlinedButtonColors(
+            contentColor = MaterialTheme.colorScheme.onBackground
+        ),
+        border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
+    ) {
+        Text(text = label)
+    }
+}
+
+@Composable
+fun TorusTextField(
+    value: String,
+    onValueChange: (String) -> Unit,
+    label: String,
+    modifier: Modifier = Modifier,
+    keyboardOptions: KeyboardOptions = KeyboardOptions.Default
+) {
+    OutlinedTextField(
+        value = value,
+        onValueChange = onValueChange,
+        modifier = modifier.fillMaxWidth(),
+        label = { Text(text = label) },
+        singleLine = true,
+        keyboardOptions = keyboardOptions,
+        shape = RoundedCornerShape(14.dp),
+        colors = torusTextFieldColors()
+    )
+}
+
+@Composable
+private fun torusTextFieldColors(): TextFieldColors {
+    return OutlinedTextFieldDefaults.colors(
+        focusedTextColor = MaterialTheme.colorScheme.onBackground,
+        unfocusedTextColor = MaterialTheme.colorScheme.onBackground,
+        focusedBorderColor = MaterialTheme.colorScheme.primary,
+        unfocusedBorderColor = MaterialTheme.colorScheme.outline,
+        focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+        unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+        cursorColor = MaterialTheme.colorScheme.primary,
+        focusedContainerColor = MaterialTheme.colorScheme.surface,
+        unfocusedContainerColor = MaterialTheme.colorScheme.surface
+    )
+}

+ 22 - 7
app/src/main/java/com/example/torus/ui/theme/Color.kt

@@ -2,10 +2,25 @@ package com.example.torus.ui.theme
 
 import androidx.compose.ui.graphics.Color
 
-val Purple80 = Color(0xFFD0BCFF)
-val PurpleGrey80 = Color(0xFFCCC2DC)
-val Pink80 = Color(0xFFEFB8C8)
-
-val Purple40 = Color(0xFF6650a4)
-val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
+val InkSunken = Color(0xFF0C0F14)
+val Ink = Color(0xFF12161C)
+val InkRaised = Color(0xFF1B222C)
+val Paper = Color(0xFFF4EFE6)
+val PaperDim = Color(0xFFD8D0C4)
+val Mist = Color(0xFF8B97A8)
+val Line = Color(0xFF2C3542)
+val LineStrong = Color(0xFF3D4A5C)
+val Ember = Color(0xFFE25B3A)
+val EmberDeep = Color(0xFFB84328)
+val EmberSoft = Color(0xFFF3B6A4)
+val EmberOn = Color(0xFF1A0D09)
+val Tide = Color(0xFF3E8F86)
+val TideDeep = Color(0xFF2A6861)
+val TideSoft = Color(0xFFB7D8D4)
+val TideOn = Color(0xFF071614)
+val Sand = Color(0xFFE7C07A)
+val SandDeep = Color(0xFFC4964A)
+val Pulse = Color(0xFFD4526E)
+val Ok = Color(0xFF6EA36A)
+val Warn = Color(0xFFD4A24A)
+val Danger = Color(0xFFC44B4B)

+ 26 - 45
app/src/main/java/com/example/torus/ui/theme/Theme.kt

@@ -1,58 +1,39 @@
 package com.example.torus.ui.theme
 
-import android.app.Activity
-import android.os.Build
-import androidx.compose.foundation.isSystemInDarkTheme
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
 
-private val DarkColorScheme = darkColorScheme(
-    primary = Purple80,
-    secondary = PurpleGrey80,
-    tertiary = Pink80
-)
-
-private val LightColorScheme = lightColorScheme(
-    primary = Purple40,
-    secondary = PurpleGrey40,
-    tertiary = Pink40
-
-    /* Other default colors to override
-    background = Color(0xFFFFFBFE),
-    surface = Color(0xFFFFFBFE),
-    onPrimary = Color.White,
-    onSecondary = Color.White,
-    onTertiary = Color.White,
-    onBackground = Color(0xFF1C1B1F),
-    onSurface = Color(0xFF1C1B1F),
-    */
+private val TorusColorScheme = darkColorScheme(
+    primary = Ember,
+    onPrimary = EmberOn,
+    primaryContainer = EmberDeep,
+    onPrimaryContainer = EmberSoft,
+    secondary = Tide,
+    onSecondary = TideOn,
+    secondaryContainer = TideDeep,
+    onSecondaryContainer = TideSoft,
+    tertiary = Sand,
+    onTertiary = EmberOn,
+    tertiaryContainer = SandDeep,
+    onTertiaryContainer = Paper,
+    background = InkSunken,
+    onBackground = Paper,
+    surface = Ink,
+    onSurface = Paper,
+    surfaceVariant = InkRaised,
+    onSurfaceVariant = PaperDim,
+    outline = Line,
+    outlineVariant = LineStrong,
+    error = Danger,
+    onError = Paper,
 )
 
 @Composable
-fun TorusTheme(
-    darkTheme: Boolean = isSystemInDarkTheme(),
-    // Dynamic color is available on Android 12+
-    dynamicColor: Boolean = true,
-    content: @Composable () -> Unit
-) {
-    val colorScheme = when {
-        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
-            val context = LocalContext.current
-            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
-        }
-
-        darkTheme -> DarkColorScheme
-        else -> LightColorScheme
-    }
-
+fun TorusTheme(content: @Composable () -> Unit) {
     MaterialTheme(
-        colorScheme = colorScheme,
+        colorScheme = TorusColorScheme,
         typography = Typography,
         content = content
     )
-}
+}

binární
app/src/main/res/drawable-nodpi/torus.png


+ 5 - 8
app/src/main/res/values/colors.xml

@@ -1,10 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <color name="purple_200">#FFBB86FC</color>
-    <color name="purple_500">#FF6200EE</color>
-    <color name="purple_700">#FF3700B3</color>
-    <color name="teal_200">#FF03DAC5</color>
-    <color name="teal_700">#FF018786</color>
-    <color name="black">#FF000000</color>
-    <color name="white">#FFFFFFFF</color>
-</resources>
+    <color name="ink_sunken">#FF0C0F14</color>
+    <color name="ink">#FF12161C</color>
+    <color name="paper">#FFF4EFE6</color>
+    <color name="ember">#FFE25B3A</color>
+</resources>

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

@@ -1,3 +1,54 @@
 <resources>
     <string name="app_name">Torus</string>
-</resources>
+    <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_distance">Distance</string>
+    <string name="exercise_name">Exercise name</string>
+    <string name="save_workout">Save Workout</string>
+    <string name="untitled_workout">Untitled workout</string>
+    <string name="start_workout">Start Workout</string>
+    <string name="resume_workout">Resume Workout</string>
+    <string name="start_new_workout">Start New Workout</string>
+    <string name="edit_workout">Edit Workout</string>
+    <string name="home">Home</string>
+    <string name="move_up">Up</string>
+    <string name="move_down">Down</string>
+    <string name="remove_exercise">Remove</string>
+    <string name="untitled_exercise">Untitled exercise</string>
+    <string name="exercise_progress">Exercise %1$d of %2$d</string>
+    <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>
+    <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>
+    <string name="exercises_menu">Exercises</string>
+    <string name="exercise_complete">Complete</string>
+    <string name="exercise_incomplete">Incomplete</string>
+    <string name="last_time">Last time</string>
+    <string name="autofill">Autofill</string>
+    <string name="last_set">Set %1$d: %2$s × %3$s</string>
+    <string name="last_empty_value">—</string>
+    <string name="last_distance">Distance: %1$s</string>
+    <string name="last_duration">Time: %1$s</string>
+</resources>

+ 6 - 3
app/src/main/res/values/themes.xml

@@ -1,5 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-
-    <style name="Theme.Torus" parent="android:Theme.Material.Light.NoActionBar" />
-</resources>
+    <style name="Theme.Torus" parent="android:Theme.Material.NoActionBar">
+        <item name="android:windowBackground">@color/ink_sunken</item>
+        <item name="android:statusBarColor">@color/ink_sunken</item>
+        <item name="android:navigationBarColor">@color/ink_sunken</item>
+    </style>
+</resources>

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

@@ -0,0 +1,35 @@
+package com.example.torus
+
+import com.example.torus.model.DistanceUnit
+import com.example.torus.model.formatAmountFromMeters
+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"))
+    }
+
+    @Test
+    fun formatAmountFromMeters_usesSelectedUnit() {
+        assertEquals("5", formatAmountFromMeters(5000.0, DistanceUnit.Kilometers))
+        assertEquals("1", formatAmountFromMeters(1609.344, DistanceUnit.Miles))
+        assertEquals("12.5", formatAmountFromMeters(12.5, DistanceUnit.Meters))
+    }
+}

+ 16 - 0
app/src/test/java/com/example/torus/ExerciseTypeTest.kt

@@ -0,0 +1,16 @@
+package com.example.torus
+
+import com.example.torus.model.ExerciseType
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ExerciseTypeTest {
+    @Test
+    fun storageValues_areStable() {
+        assertEquals("weights", ExerciseType.Weights.storageValue)
+        assertEquals("distance", ExerciseType.Distance.storageValue)
+        assertEquals(ExerciseType.Weights, ExerciseType.fromStorage("weights"))
+        assertEquals(ExerciseType.Distance, ExerciseType.fromStorage("distance"))
+        assertEquals(ExerciseType.Distance, ExerciseType.fromStorage("run"))
+    }
+}

+ 52 - 0
app/src/test/java/com/example/torus/LastExerciseFormatTest.kt

@@ -0,0 +1,52 @@
+package com.example.torus
+
+import com.example.torus.model.ExerciseType
+import com.example.torus.model.LastExerciseLog
+import com.example.torus.model.LastWeightSet
+import com.example.torus.model.formatDistanceMeters
+import com.example.torus.model.formatDurationSeconds
+import com.example.torus.model.hasRecordedData
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class LastExerciseFormatTest {
+    @Test
+    fun formatDistanceMeters_usesKmAfterOneThousand() {
+        assertEquals("500.0 m", formatDistanceMeters(500.0))
+        assertEquals("5.00 km", formatDistanceMeters(5000.0))
+    }
+
+    @Test
+    fun formatDurationSeconds_includesHoursWhenNeeded() {
+        assertEquals("2:03", formatDurationSeconds(123))
+        assertEquals("1:02:03", formatDurationSeconds(3723))
+    }
+
+    @Test
+    fun hasRecordedData_isFalseWhenLastSessionWasEmpty() {
+        assertFalse(
+            LastExerciseLog(
+                type = ExerciseType.Weights,
+                sets = listOf(LastWeightSet(weight = "", reps = ""))
+            ).hasRecordedData()
+        )
+        assertFalse(
+            LastExerciseLog(type = ExerciseType.Distance).hasRecordedData()
+        )
+        assertTrue(
+            LastExerciseLog(
+                type = ExerciseType.Weights,
+                sets = listOf(LastWeightSet(weight = "135", reps = "8"))
+            ).hasRecordedData()
+        )
+        assertTrue(
+            LastExerciseLog(
+                type = ExerciseType.Distance,
+                distanceMeters = 5000.0,
+                durationSeconds = 1500
+            ).hasRecordedData()
+        )
+    }
+}

+ 28 - 0
app/src/test/java/com/example/torus/UuidV7Test.kt

@@ -0,0 +1,28 @@
+package com.example.torus
+
+import com.example.torus.id.UuidV7
+import java.util.UUID
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class UuidV7Test {
+    @Test
+    fun generate_isVersion7Rfc4122() {
+        val uuid = UuidV7.generate()
+        assertEquals(7, uuid.version())
+        assertEquals(2, uuid.variant())
+    }
+
+    @Test
+    fun generate_isUniqueAndTimeOrdered() {
+        val ids = List(256) { UuidV7.generateString() }
+        assertEquals(ids.size, ids.toSet().size)
+        assertEquals(ids.sorted(), ids)
+        ids.forEach { value ->
+            val uuid = UUID.fromString(value)
+            assertEquals(7, uuid.version())
+            assertEquals(2, uuid.variant())
+        }
+    }
+}

+ 1 - 0
build.gradle.kts

@@ -2,4 +2,5 @@
 plugins {
     alias(libs.plugins.android.application) apply false
     alias(libs.plugins.kotlin.compose) apply false
+    alias(libs.plugins.ksp) apply false
 }

+ 2 - 1
gradle.properties

@@ -16,4 +16,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
 # has changed. Additionally, Gradle applies performance optimizations to task execution.
 org.gradle.configuration-cache=true
 # Kotlin code style for this project: "official" or "obsolete":
-kotlin.code.style=official
+kotlin.code.style=official
+android.disallowKotlinSourceSets=false

+ 6 - 0
gradle/libs.versions.toml

@@ -7,7 +7,9 @@ espressoCore = "3.5.1"
 lifecycleRuntimeKtx = "2.6.1"
 activityCompose = "1.8.0"
 kotlin = "2.2.10"
+ksp = "2.2.10-2.0.2"
 composeBom = "2026.02.01"
+room = "2.7.2"
 
 [libraries]
 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,8 +26,12 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
 androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
 androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
 androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
 
 [plugins]
 android-application = { id = "com.android.application", version.ref = "agp" }
 kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
 

binární
res/drawable/torus.png