Jelajahi Sumber

Create basics of the workout recording

Lee Morgan 2 minggu lalu
induk
melakukan
a1602883e6

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

@@ -0,0 +1,319 @@
+package com.example.torus.ui
+
+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.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.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+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.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.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.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.ExerciseType
+import com.example.torus.model.SavedExercise
+import com.example.torus.model.WorkoutDetail
+import com.example.torus.ui.theme.Sand
+import com.example.torus.ui.theme.TorusTheme
+
+private data class WeightSetDraft(
+    val id: String,
+    val weight: String = "",
+    val reps: String = ""
+)
+
+@Composable
+fun StartWorkoutScreen(
+    workoutId: String,
+    workoutRepository: WorkoutRepository,
+    modifier: Modifier = Modifier
+) {
+    var detail by remember(workoutId) { mutableStateOf<WorkoutDetail?>(null) }
+    var exerciseIndex by remember(workoutId) { mutableIntStateOf(0) }
+    val setsByExercise = remember(workoutId) {
+        mutableStateMapOf<String, SnapshotStateList<WeightSetDraft>>()
+    }
+
+    LaunchedEffect(workoutId) {
+        detail = workoutRepository.getWorkoutDetail(workoutId)
+        exerciseIndex = 0
+        setsByExercise.clear()
+    }
+
+    val workout = detail ?: return
+    StartWorkoutContent(
+        workout = workout,
+        exerciseIndex = exerciseIndex,
+        setsByExercise = setsByExercise,
+        modifier = modifier,
+        onNextExercise = { exerciseIndex += 1 }
+    )
+}
+
+@Composable
+private fun StartWorkoutContent(
+    workout: WorkoutDetail,
+    exerciseIndex: Int,
+    setsByExercise: MutableMap<String, SnapshotStateList<WeightSetDraft>>,
+    modifier: Modifier = Modifier,
+    onNextExercise: () -> Unit = {}
+) {
+    val exercises = workout.exercises
+    if (exercises.isEmpty()) {
+        Text(
+            text = stringResource(R.string.no_exercises),
+            modifier = modifier.padding(16.dp),
+            color = MaterialTheme.colorScheme.onBackground,
+            style = MaterialTheme.typography.bodyLarge
+        )
+        return
+    }
+
+    val currentIndex = exerciseIndex.coerceIn(0, exercises.lastIndex)
+    val exercise = exercises[currentIndex]
+    val isLastExercise = currentIndex == exercises.lastIndex
+
+    Column(modifier = modifier.fillMaxSize()) {
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState())
+                .padding(horizontal = 16.dp, vertical = 8.dp),
+            verticalArrangement = Arrangement.spacedBy(16.dp)
+        ) {
+            Text(
+                text = workout.name.ifBlank { stringResource(R.string.untitled_workout) },
+                color = Sand,
+                style = MaterialTheme.typography.headlineMedium
+            )
+            Text(
+                text = stringResource(
+                    R.string.exercise_progress,
+                    currentIndex + 1,
+                    exercises.size
+                ),
+                color = MaterialTheme.colorScheme.onSurfaceVariant,
+                style = MaterialTheme.typography.titleSmall
+            )
+            Text(
+                text = exercise.name.ifBlank { stringResource(R.string.untitled_exercise) },
+                color = MaterialTheme.colorScheme.onBackground,
+                style = MaterialTheme.typography.headlineSmall
+            )
+            Text(
+                text = stringResource(exercise.type.labelRes),
+                color = MaterialTheme.colorScheme.onSurfaceVariant,
+                style = MaterialTheme.typography.labelLarge
+            )
+            if (exercise.type == ExerciseType.Weights) {
+                val sets = setsByExercise.getOrPut(exercise.id) { defaultWeightSets() }
+                WeightSetsEditor(
+                    sets = sets,
+                    onAddSet = { sets += WeightSetDraft(id = UuidV7.generateString()) },
+                    onRemoveSet = { setId ->
+                        if (sets.size > 1) {
+                            sets.removeAll { it.id == setId }
+                        }
+                    },
+                    onWeightChange = { setId, weight ->
+                        updateSet(sets, setId) { it.copy(weight = weight) }
+                    },
+                    onRepsChange = { setId, reps ->
+                        updateSet(sets, setId) { it.copy(reps = reps) }
+                    }
+                )
+            }
+        }
+        if (!isLastExercise) {
+            Button(
+                onClick = onNextExercise,
+                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.next_exercise))
+            }
+        }
+    }
+}
+
+@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
+                )
+            }
+        }
+    }
+}
+
+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 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.Run,
+                        sortOrder = 1,
+                        isActive = true,
+                        notes = ""
+                    )
+                )
+            ),
+            exerciseIndex = 0,
+            setsByExercise = mutableStateMapOf()
+        )
+    }
+}

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

@@ -27,9 +27,15 @@ private object Routes {
 
     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
@@ -84,10 +90,16 @@ fun TorusApp(workoutRepository: WorkoutRepository) {
                     workoutId = workoutId,
                     workoutRepository = workoutRepository,
                     modifier = contentModifier,
+                    onStartWorkout = { navigateTo(Routes.start(workoutId)) },
                     onEditWorkout = { navigateTo(Routes.edit(workoutId)) },
                     onHome = { goHome() }
                 )
             }
+            Routes.isStart(currentRoute) -> StartWorkoutScreen(
+                workoutId = Routes.startId(currentRoute),
+                workoutRepository = workoutRepository,
+                modifier = contentModifier
+            )
             Routes.isEdit(currentRoute) -> {
                 val workoutId = Routes.editId(currentRoute)
                 EditWorkoutScreen(

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

@@ -9,6 +9,7 @@ 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
@@ -168,7 +169,8 @@ fun TorusTextField(
     value: String,
     onValueChange: (String) -> Unit,
     label: String,
-    modifier: Modifier = Modifier
+    modifier: Modifier = Modifier,
+    keyboardOptions: KeyboardOptions = KeyboardOptions.Default
 ) {
     OutlinedTextField(
         value = value,
@@ -176,6 +178,7 @@ fun TorusTextField(
         modifier = modifier.fillMaxWidth(),
         label = { Text(text = label) },
         singleLine = true,
+        keyboardOptions = keyboardOptions,
         shape = RoundedCornerShape(14.dp),
         colors = torusTextFieldColors()
     )

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

@@ -16,4 +16,13 @@
     <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="add_set">Add set</string>
+    <string name="remove_set">Remove set</string>
+    <string name="next_exercise">Next exercise</string>
+    <string name="no_exercises">This workout has no exercises.</string>
 </resources>