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