소스 검색

Display previous exercise data during sessions

Lee Morgan 2 주 전
부모
커밋
27464e8ee2

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

@@ -179,6 +179,54 @@ class WorkoutRepositoryTest {
         assertEquals(1, benchSets[1].sortOrder)
         assertEquals(emptyList<Any>(), database.sessionDao().getSessionSets(sessionExercises[1].id))
     }
+
+    @Test
+    fun getLastExerciseLogs_returnsMostRecentSession() = runBlocking {
+        val benchId = UuidV7.generateString()
+        val workoutId = repository.saveWorkout(
+            name = "Push day",
+            exercises = listOf(
+                DraftExercise(id = benchId, type = ExerciseType.Weights, name = "Bench")
+            )
+        )
+        repository.completeSession(
+            workoutId = workoutId,
+            startedAt = 1L,
+            finishedAt = 2L,
+            exercises = listOf(
+                SessionExerciseInput(
+                    workoutExerciseId = benchId,
+                    name = "Bench",
+                    type = ExerciseType.Weights,
+                    sets = listOf(SessionSetInput(weight = "95", reps = "10"))
+                )
+            )
+        )
+        repository.completeSession(
+            workoutId = workoutId,
+            startedAt = 3L,
+            finishedAt = 4L,
+            exercises = listOf(
+                SessionExerciseInput(
+                    workoutExerciseId = benchId,
+                    name = "Bench",
+                    type = ExerciseType.Weights,
+                    sets = listOf(
+                        SessionSetInput(weight = "135", reps = "8"),
+                        SessionSetInput(weight = "155", reps = "5")
+                    )
+                )
+            )
+        )
+
+        val logs = repository.getLastExerciseLogs(workoutId)
+        val bench = logs.getValue(benchId)
+        assertEquals(2, bench.sets.size)
+        assertEquals("135", bench.sets[0].weight)
+        assertEquals("8", bench.sets[0].reps)
+        assertEquals("155", bench.sets[1].weight)
+        assertEquals("5", bench.sets[1].reps)
+    }
 }
 
 private fun assertUuidV7(value: String) {

+ 10 - 0
app/src/main/java/com/example/torus/data/SessionDao.kt

@@ -18,6 +18,16 @@ interface SessionDao {
     @Query("SELECT * FROM session WHERE id = :id")
     suspend fun getSession(id: String): SessionEntity?
 
+    @Query(
+        """
+        SELECT * FROM session
+        WHERE workout_id = :workoutId
+        ORDER BY finished_at DESC, id DESC
+        LIMIT 1
+        """
+    )
+    suspend fun getLatestSessionForWorkout(workoutId: String): SessionEntity?
+
     @Query("SELECT * FROM session_exercise WHERE session_id = :sessionId ORDER BY sort_order ASC")
     suspend fun getSessionExercises(sessionId: String): List<SessionExerciseEntity>
 

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

@@ -4,6 +4,8 @@ import androidx.room.withTransaction
 import com.example.torus.id.UuidV7
 import com.example.torus.model.DraftExercise
 import com.example.torus.model.ExerciseType
+import com.example.torus.model.LastExerciseLog
+import com.example.torus.model.LastWeightSet
 import com.example.torus.model.SavedExercise
 import com.example.torus.model.SessionExerciseInput
 import com.example.torus.model.WorkoutDetail
@@ -42,6 +44,22 @@ class WorkoutRepository(private val database: TorusDatabase) {
         )
     }
 
+    suspend fun getLastExerciseLogs(workoutId: String): Map<String, LastExerciseLog> {
+        val session = sessionDao.getLatestSessionForWorkout(workoutId) ?: return emptyMap()
+        val exercises = sessionDao.getSessionExercises(session.id)
+        return exercises.associate { exercise ->
+            val sets = sessionDao.getSessionSets(exercise.id).map { set ->
+                LastWeightSet(weight = set.weight, reps = set.reps)
+            }
+            exercise.workoutExerciseId to LastExerciseLog(
+                type = ExerciseType.fromStorage(exercise.type),
+                sets = sets,
+                distanceMeters = exercise.distanceMeters,
+                durationSeconds = exercise.durationSeconds
+            )
+        }
+    }
+
     suspend fun saveWorkout(name: String, exercises: List<DraftExercise>): String {
         return database.withTransaction {
             val workoutId = UuidV7.generateString()

+ 32 - 0
app/src/main/java/com/example/torus/model/LastExerciseLog.kt

@@ -0,0 +1,32 @@
+package com.example.torus.model
+
+data class LastExerciseLog(
+    val type: ExerciseType,
+    val sets: List<LastWeightSet> = emptyList(),
+    val distanceMeters: Double = 0.0,
+    val durationSeconds: Int = 0,
+)
+
+data class LastWeightSet(
+    val weight: String,
+    val reps: String,
+)
+
+fun formatDistanceMeters(meters: Double): String {
+    return if (meters >= 1000.0) {
+        String.format(java.util.Locale.US, "%.2f km", meters / 1000.0)
+    } else {
+        String.format(java.util.Locale.US, "%.1f m", meters)
+    }
+}
+
+fun formatDurationSeconds(totalSeconds: Int): String {
+    val hours = totalSeconds / 3600
+    val minutes = (totalSeconds % 3600) / 60
+    val seconds = totalSeconds % 60
+    return if (hours > 0) {
+        String.format(java.util.Locale.US, "%d:%02d:%02d", hours, minutes, seconds)
+    } else {
+        String.format(java.util.Locale.US, "%d:%02d", minutes, seconds)
+    }
+}

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

@@ -48,6 +48,9 @@ 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.LastExerciseLog
+import com.example.torus.model.formatDistanceMeters
+import com.example.torus.model.formatDurationSeconds
 import com.example.torus.model.parseDistanceMeters
 import com.example.torus.model.parseDurationSeconds
 import com.example.torus.model.SavedExercise
@@ -83,6 +86,7 @@ fun StartWorkoutScreen(
     var detail by remember(workoutId) { mutableStateOf<WorkoutDetail?>(null) }
     var exerciseIndex by remember(workoutId) { mutableIntStateOf(0) }
     var isCompleting by remember { mutableStateOf(false) }
+    var lastLogs by remember(workoutId) { mutableStateOf<Map<String, LastExerciseLog>>(emptyMap()) }
     val startedAt = remember(workoutId) { System.currentTimeMillis() }
     val setsByExercise = remember(workoutId) {
         mutableStateMapOf<String, SnapshotStateList<WeightSetDraft>>()
@@ -103,6 +107,7 @@ fun StartWorkoutScreen(
                 ExerciseType.Distance -> distanceByExercise[exercise.id] = DistanceDraft()
             }
         }
+        lastLogs = workoutRepository.getLastExerciseLogs(workoutId)
     }
 
     val workout = detail ?: return
@@ -111,6 +116,7 @@ fun StartWorkoutScreen(
         exerciseIndex = exerciseIndex,
         setsByExercise = setsByExercise,
         distanceByExercise = distanceByExercise,
+        lastLogs = lastLogs,
         isCompleting = isCompleting,
         modifier = modifier,
         onNextExercise = { exerciseIndex += 1 },
@@ -133,6 +139,7 @@ private fun StartWorkoutContent(
     exerciseIndex: Int,
     setsByExercise: MutableMap<String, SnapshotStateList<WeightSetDraft>>,
     distanceByExercise: MutableMap<String, DistanceDraft>,
+    lastLogs: Map<String, LastExerciseLog> = emptyMap(),
     isCompleting: Boolean = false,
     modifier: Modifier = Modifier,
     onNextExercise: () -> Unit = {},
@@ -195,6 +202,9 @@ private fun StartWorkoutContent(
                     color = MaterialTheme.colorScheme.onSurfaceVariant,
                     style = MaterialTheme.typography.labelLarge
                 )
+                lastLogs[exercise.id]?.let { log ->
+                    LastTimeCard(log = log)
+                }
                 when (exercise.type) {
                     ExerciseType.Weights -> {
                         val sets = setsByExercise.getOrPut(exercise.id) { defaultWeightSets() }
@@ -275,6 +285,64 @@ private fun StartWorkoutContent(
     }
 }
 
+@Composable
+private fun LastTimeCard(log: LastExerciseLog) {
+    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)
+    ) {
+        Text(
+            text = stringResource(R.string.last_time),
+            color = Sand,
+            style = MaterialTheme.typography.titleSmall
+        )
+        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>,

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

@@ -43,4 +43,9 @@
     <string name="exercises_menu">Exercises</string>
     <string name="exercise_complete">Complete</string>
     <string name="exercise_incomplete">Incomplete</string>
+    <string name="last_time">Last time</string>
+    <string name="last_set">Set %1$d: %2$s × %3$s</string>
+    <string name="last_empty_value">—</string>
+    <string name="last_distance">Distance: %1$s</string>
+    <string name="last_duration">Time: %1$s</string>
 </resources>

+ 20 - 0
app/src/test/java/com/example/torus/LastExerciseFormatTest.kt

@@ -0,0 +1,20 @@
+package com.example.torus
+
+import com.example.torus.model.formatDistanceMeters
+import com.example.torus.model.formatDurationSeconds
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class LastExerciseFormatTest {
+    @Test
+    fun formatDistanceMeters_usesKmAfterOneThousand() {
+        assertEquals("500.0 m", formatDistanceMeters(500.0))
+        assertEquals("5.00 km", formatDistanceMeters(5000.0))
+    }
+
+    @Test
+    fun formatDurationSeconds_includesHoursWhenNeeded() {
+        assertEquals("2:03", formatDurationSeconds(123))
+        assertEquals("1:02:03", formatDurationSeconds(3723))
+    }
+}