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