Forráskód Böngészése

Save new workouts to sqlite

Lee Morgan 2 hete
szülő
commit
2024f3d095

+ 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">

+ 4 - 0
AGENTS.md

@@ -39,3 +39,7 @@ Ember button text is `#1a0d09`, not paper. Tide button text is `#071614`.
 - 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)

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

@@ -0,0 +1,82 @@
+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 java.util.UUID
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+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.Run,
+                    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)
+        assertTrue(exercises[0].isActive)
+        assertEquals("pause reps", exercises[0].notes)
+        assertEquals("Easy run", exercises[1].name)
+        assertEquals("run", exercises[1].type)
+        assertTrue(exercises[1].isActive)
+        assertEquals("", exercises[1].notes)
+        exercises.forEach { assertUuidV7(it.id) }
+        assertUuidV7(exercises[0].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"

+ 2 - 1
app/src/main/java/com/example/torus/MainActivity.kt

@@ -11,9 +11,10 @@ class MainActivity : ComponentActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         enableEdgeToEdge()
+        val workoutRepository = (application as TorusApplication).workoutRepository
         setContent {
             TorusTheme {
-                TorusApp()
+                TorusApp(workoutRepository = workoutRepository)
             }
         }
     }

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

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

@@ -0,0 +1,25 @@
+package com.example.torus.data
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+@Database(
+    entities = [WorkoutEntity::class, WorkoutExerciseEntity::class],
+    version = 2,
+    exportSchema = false
+)
+abstract class TorusDatabase : RoomDatabase() {
+    abstract fun workoutDao(): WorkoutDao
+
+    companion object {
+        fun create(context: Context): TorusDatabase {
+            return Room.databaseBuilder(
+                context.applicationContext,
+                TorusDatabase::class.java,
+                "torus.db"
+            ).fallbackToDestructiveMigration(dropAllTables = true).build()
+        }
+    }
+}

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

@@ -0,0 +1,20 @@
+package com.example.torus.data
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+
+@Dao
+interface WorkoutDao {
+    @Insert
+    suspend fun insertWorkout(workout: WorkoutEntity)
+
+    @Insert
+    suspend fun insertExercises(exercises: List<WorkoutExerciseEntity>)
+
+    @Query("SELECT * FROM workout WHERE id = :id")
+    suspend fun getWorkout(id: String): WorkoutEntity?
+
+    @Query("SELECT * FROM workout_exercise WHERE workout_id = :workoutId ORDER BY id 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,
+)

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

@@ -0,0 +1,28 @@
+package com.example.torus.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "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 = "is_active") val isActive: Boolean,
+    val notes: String,
+)

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

@@ -0,0 +1,36 @@
+package com.example.torus.data
+
+import androidx.room.withTransaction
+import com.example.torus.id.UuidV7
+import com.example.torus.model.DraftExercise
+
+class WorkoutRepository(private val database: TorusDatabase) {
+    private val workoutDao = database.workoutDao()
+
+    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.map { exercise ->
+                        WorkoutExerciseEntity(
+                            id = exercise.id,
+                            workoutId = workoutId,
+                            name = exercise.name.trim(),
+                            type = exercise.type.storageValue,
+                            isActive = exercise.isActive,
+                            notes = exercise.notes.trim()
+                        )
+                    }
+                )
+            }
+            workoutId
+        }
+    }
+}

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

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

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

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

+ 47 - 22
app/src/main/java/com/example/torus/ui/CreateWorkoutScreen.kt

@@ -36,32 +36,27 @@ 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 java.util.UUID
 import kotlinx.coroutines.launch
 
-private enum class ExerciseType {
-    Weights,
-    Run,
-}
-
-private data class DraftExercise(
-    val id: String,
-    val type: ExerciseType,
-    val name: String = "",
-)
-
 @Composable
-fun CreateWorkoutScreen(modifier: Modifier = Modifier) {
+fun CreateWorkoutScreen(
+    modifier: Modifier = Modifier,
+    onSaveWorkout: (workoutName: String, exercises: List<DraftExercise>) -> 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 = UUID.randomUUID().toString(), type = type)
+        exercises += DraftExercise(id = UuidV7.generateString(), type = type)
         showExerciseTypes = false
     }
 
@@ -90,11 +85,12 @@ fun CreateWorkoutScreen(modifier: Modifier = Modifier) {
                         number = index + 1,
                         typeLabel = stringResource(exercise.type.labelRes),
                         name = exercise.name,
+                        notes = exercise.notes,
                         onNameChange = { name ->
-                            val exerciseIndex = exercises.indexOfFirst { it.id == exercise.id }
-                            if (exerciseIndex >= 0) {
-                                exercises[exerciseIndex] = exercises[exerciseIndex].copy(name = name)
-                            }
+                            updateExercise(exercises, exercise.id) { it.copy(name = name) }
+                        },
+                        onNotesChange = { notes ->
+                            updateExercise(exercises, exercise.id) { it.copy(notes = notes) }
                         }
                     )
                 }
@@ -124,7 +120,13 @@ fun CreateWorkoutScreen(modifier: Modifier = Modifier) {
             }
         }
         Button(
-            onClick = {},
+            onClick = {
+                if (!isSaving) {
+                    isSaving = true
+                    onSaveWorkout(workoutName, exercises.toList())
+                }
+            },
+            enabled = !isSaving,
             modifier = Modifier
                 .fillMaxWidth()
                 .padding(horizontal = 16.dp, vertical = 12.dp),
@@ -145,12 +147,25 @@ private val ExerciseType.labelRes: Int
         ExerciseType.Run -> R.string.exercise_type_run
     }
 
+private 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])
+    }
+}
+
 @Composable
 private fun NamedExerciseEditor(
     number: Int,
     typeLabel: String,
     name: String,
-    onNameChange: (String) -> Unit
+    notes: String,
+    onNameChange: (String) -> Unit,
+    onNotesChange: (String) -> Unit
 ) {
     val shape = RoundedCornerShape(14.dp)
     Column(
@@ -181,6 +196,13 @@ private fun NamedExerciseEditor(
             onValueChange = onNameChange,
             label = stringResource(R.string.exercise_name)
         )
+        TorusTextField(
+            value = notes,
+            onValueChange = onNotesChange,
+            label = stringResource(R.string.exercise_notes),
+            singleLine = false,
+            minLines = 3
+        )
     }
 }
 
@@ -237,14 +259,17 @@ private fun TorusTextField(
     value: String,
     onValueChange: (String) -> Unit,
     label: String,
-    modifier: Modifier = Modifier
+    modifier: Modifier = Modifier,
+    singleLine: Boolean = true,
+    minLines: Int = 1
 ) {
     OutlinedTextField(
         value = value,
         onValueChange = onValueChange,
         modifier = modifier.fillMaxWidth(),
         label = { Text(text = label) },
-        singleLine = true,
+        singleLine = singleLine,
+        minLines = minLines,
         shape = RoundedCornerShape(14.dp),
         colors = torusTextFieldColors()
     )

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

@@ -7,9 +7,12 @@ import androidx.compose.material3.Scaffold
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
+import com.example.torus.data.WorkoutRepository
+import kotlinx.coroutines.launch
 
 private enum class Screen {
     Home,
@@ -17,8 +20,9 @@ private enum class Screen {
 }
 
 @Composable
-fun TorusApp() {
+fun TorusApp(workoutRepository: WorkoutRepository) {
     var screen by rememberSaveable { mutableStateOf(Screen.Home) }
+    val coroutineScope = rememberCoroutineScope()
 
     BackHandler(enabled = screen != Screen.Home) {
         screen = Screen.Home
@@ -34,7 +38,15 @@ fun TorusApp() {
                 modifier = contentModifier,
                 onAddExercise = { screen = Screen.CreateWorkout }
             )
-            Screen.CreateWorkout -> CreateWorkoutScreen(modifier = contentModifier)
+            Screen.CreateWorkout -> CreateWorkoutScreen(
+                modifier = contentModifier,
+                onSaveWorkout = { name, exercises ->
+                    coroutineScope.launch {
+                        workoutRepository.saveWorkout(name, exercises)
+                        screen = Screen.Home
+                    }
+                }
+            )
         }
     }
 }

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

@@ -8,5 +8,6 @@
     <string name="exercise_type_weights">Weights</string>
     <string name="exercise_type_run">Run</string>
     <string name="exercise_name">Exercise name</string>
+    <string name="exercise_notes">Notes</string>
     <string name="save_workout">Save Workout</string>
 </resources>

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

@@ -0,0 +1,15 @@
+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("run", ExerciseType.Run.storageValue)
+        assertEquals(ExerciseType.Weights, ExerciseType.fromStorage("weights"))
+        assertEquals(ExerciseType.Run, ExerciseType.fromStorage("run"))
+    }
+}

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