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