diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/data/db/dao/WorkoutDao.kt b/app/src/main/java/io/github/prithvimk/gymtracker/data/db/dao/WorkoutDao.kt index 2926127..e5a805c 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/data/db/dao/WorkoutDao.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/data/db/dao/WorkoutDao.kt @@ -44,6 +44,12 @@ data class WorkoutHistoryRow( val recordCount: Int, ) +/** A completed set other than a warm-up, from the last finished workout its exercise was done in. */ +data class LastSessionSet(val exerciseId: String, val startedAt: Long, val weightKg: Double, val reps: Int) + +/** The name of one exercise in a workout, for the history list. */ +data class WorkoutExerciseName(val workoutId: String, val name: String) + /** A completed set of an exercise, with the workout it was done in, for the exercise's history. */ data class ExerciseHistoryRow( val workoutId: String, @@ -74,6 +80,49 @@ interface WorkoutDao { ) fun observeHistory(): Flow> + /** + * For every exercise, its completed sets other than warm-ups from the last finished workout that had + * any, for the exercise list's "last top set". + */ + @Query( + """ + SELECT we.exerciseId, w.startedAt, s.weightKg, s.reps FROM workout_sets s + JOIN workout_exercises we ON we.id = s.workoutExerciseId JOIN workouts w ON w.id = we.workoutId + WHERE s.completedAt IS NOT NULL AND s.type != 'WARMUP' AND w.endedAt IS NOT NULL + AND w.startedAt = ( + SELECT MAX(w2.startedAt) FROM workout_sets s2 + JOIN workout_exercises we2 ON we2.id = s2.workoutExerciseId JOIN workouts w2 ON w2.id = we2.workoutId + WHERE we2.exerciseId = we.exerciseId AND s2.completedAt IS NOT NULL AND s2.type != 'WARMUP' + AND w2.endedAt IS NOT NULL + ) + """ + ) + fun observeLastSessionSets(): Flow> + + /** + * The exercises of the last [workoutCount] finished workouts, most recent workout first and each in + * order. An exercise can appear more than once. + */ + @Query( + """ + SELECT we.exerciseId FROM workout_exercises we JOIN workouts w ON w.id = we.workoutId + WHERE w.id IN (SELECT id FROM workouts WHERE endedAt IS NOT NULL ORDER BY startedAt DESC LIMIT :workoutCount) + ORDER BY w.startedAt DESC, we.position + """ + ) + fun observeRecentExerciseIds(workoutCount: Int): Flow> + + /** Exercise names of every finished workout, each workout's in order. */ + @Query( + """ + SELECT we.workoutId, e.name FROM workout_exercises we + JOIN workouts w ON w.id = we.workoutId JOIN exercises e ON e.id = we.exerciseId + WHERE w.endedAt IS NOT NULL + ORDER BY we.workoutId, we.position + """ + ) + fun observeHistoryExerciseNames(): Flow> + /** * Every completed set of the exercise, the active workout's included: newest workout first, then in * the order the logger shows them. diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/data/repository/WorkoutRepository.kt b/app/src/main/java/io/github/prithvimk/gymtracker/data/repository/WorkoutRepository.kt index 4bd12f6..b03e26a 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/data/repository/WorkoutRepository.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/data/repository/WorkoutRepository.kt @@ -10,15 +10,19 @@ import io.github.prithvimk.gymtracker.data.db.entity.PersonalRecord import io.github.prithvimk.gymtracker.data.db.entity.Workout import io.github.prithvimk.gymtracker.data.db.entity.WorkoutExercise import io.github.prithvimk.gymtracker.data.db.entity.WorkoutSet +import io.github.prithvimk.gymtracker.domain.LastTopSet import io.github.prithvimk.gymtracker.domain.SessionComparison import io.github.prithvimk.gymtracker.domain.SessionExercise +import io.github.prithvimk.gymtracker.domain.TopSet import io.github.prithvimk.gymtracker.domain.WorkoutSummary +import io.github.prithvimk.gymtracker.domain.bestOf import io.github.prithvimk.gymtracker.domain.compareSessions import io.github.prithvimk.gymtracker.domain.model.SetType import io.github.prithvimk.gymtracker.domain.summarizeWorkout import java.util.UUID import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map /** What [WorkoutRepository.endIdleWorkout] did with the active workout. */ sealed interface IdleWorkoutResult { @@ -190,6 +194,24 @@ class WorkoutRepository( /** Finished workouts for the history list, newest first. */ fun observeHistory(): Flow> = workoutDao.observeHistory() + /** Each trained exercise's top set from the last finished workout it was done in, keyed by exercise id. */ + fun observeLastTopSets(): Flow> = workoutDao.observeLastSessionSets().map { rows -> + rows.groupBy { it.exerciseId }.mapValues { (_, sets) -> + // Two workouts can't start at the same moment in practice, but the latest wins if they do. + val startedAt = sets.maxOf { it.startedAt } + val top = bestOf(sets.filter { it.startedAt == startedAt }.map { TopSet(it.weightKg, it.reps) }) + LastTopSet(checkNotNull(top), startedAt) + } + } + + /** Exercises from the last few finished workouts, most recently done first, each once. */ + fun observeRecentExerciseIds(): Flow> = + workoutDao.observeRecentExerciseIds(RECENT_WORKOUTS).map { it.distinct() } + + /** The exercise names of each finished workout, in order, keyed by workout id. */ + fun observeHistoryExerciseNames(): Flow>> = + workoutDao.observeHistoryExerciseNames().map { rows -> rows.groupBy({ it.workoutId }, { it.name }) } + /** Every completed set of the exercise with its workout, newest workout first. */ fun observeExerciseHistory(exerciseId: String): Flow> = workoutDao.observeExerciseHistory(exerciseId) @@ -270,5 +292,8 @@ class WorkoutRepository( companion object { /** How long an active workout can go untouched before [endIdleWorkout] ends it: 3 hours. */ const val IDLE_LIMIT_MILLIS = 3 * 60 * 60 * 1_000L + + /** How many finished workouts the add-exercises sheet takes its Recent exercises from. */ + const val RECENT_WORKOUTS = 5 } } diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/domain/SessionComparison.kt b/app/src/main/java/io/github/prithvimk/gymtracker/domain/SessionComparison.kt index 6aa14c7..333a49c 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/domain/SessionComparison.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/domain/SessionComparison.kt @@ -54,10 +54,15 @@ data class SessionComparison( * The top set among the completed sets that aren't warm-ups: the heaviest, and of the heaviest the * one with the most reps. Null if there is no such set. */ -fun topSet(sets: List): TopSet? = sets - .filter { it.completedAt != null && it.type != SetType.WARMUP } - .maxWithOrNull(compareBy { it.weightKg }.thenBy { it.reps }) - ?.let { TopSet(it.weightKg, it.reps) } +fun topSet(sets: List): TopSet? = bestOf( + sets.filter { it.completedAt != null && it.type != SetType.WARMUP }.map { TopSet(it.weightKg, it.reps) }, +) + +/** The heaviest of [sets], and of the heaviest the one with the most reps. */ +fun bestOf(sets: List): TopSet? = sets.maxWithOrNull(compareBy { it.weightKg }.thenBy { it.reps }) + +/** An exercise's top set from the last finished workout it was done in, and when that workout started. */ +data class LastTopSet(val topSet: TopSet, val startedAt: Long) /** * Compares a session with the previous one. Exercises are matched by library exercise, an exercise diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/GymTrackerRoot.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/GymTrackerRoot.kt index b1f396a..402c247 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/GymTrackerRoot.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/GymTrackerRoot.kt @@ -101,7 +101,17 @@ fun GymTrackerRoot() { WORKOUT_DETAIL_ROUTE, arguments = listOf(navArgument(WORKOUT_ID) { type = NavType.StringType }), ) { - WorkoutDetailRoute(onBack = { navController.popBackStack() }) + WorkoutDetailRoute( + onBack = { navController.popBackStack() }, + onExerciseClick = { navController.navigate(historyExerciseRoute(it)) }, + ) + } + // The same exercise detail as under Exercises, kept under History so that tab stays selected. + composable( + HISTORY_EXERCISE_ROUTE, + arguments = listOf(navArgument(EXERCISE_ID) { type = NavType.StringType }), + ) { + ExerciseDetailRoute(onBack = { navController.popBackStack() }) } composable(TopLevelDestination.EXERCISES.route) { ExercisesRoute(onExerciseClick = { navController.navigate(exerciseDetailRoute(it.id)) }) @@ -125,6 +135,10 @@ private const val EXERCISE_DETAIL_ROUTE = "exercises/{$EXERCISE_ID}" private fun exerciseDetailRoute(id: String): String = "exercises/$id" +private const val HISTORY_EXERCISE_ROUTE = "history/exercise/{$EXERCISE_ID}" + +private fun historyExerciseRoute(id: String): String = "history/exercise/$id" + private const val WORKOUT_ID = WorkoutDetailViewModel.WORKOUT_ID_ARG private const val WORKOUT_DETAIL_ROUTE = "history/{$WORKOUT_ID}" diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseDetailScreen.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseDetailScreen.kt index 40866e5..71b9aa4 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseDetailScreen.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseDetailScreen.kt @@ -3,19 +3,25 @@ package io.github.prithvimk.gymtracker.ui.exercises import androidx.annotation.StringRes +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Edit @@ -32,9 +38,17 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -47,16 +61,17 @@ import io.github.prithvimk.gymtracker.domain.model.Equipment import io.github.prithvimk.gymtracker.domain.model.MuscleGroup import io.github.prithvimk.gymtracker.domain.model.PrType import io.github.prithvimk.gymtracker.domain.model.SetType +import io.github.prithvimk.gymtracker.ui.format.signedVolumeText import io.github.prithvimk.gymtracker.ui.format.volumeText import io.github.prithvimk.gymtracker.ui.format.weightText import io.github.prithvimk.gymtracker.ui.history.formatDate import io.github.prithvimk.gymtracker.ui.history.formatStartTime import io.github.prithvimk.gymtracker.ui.theme.GymTrackerTheme import io.github.prithvimk.gymtracker.ui.theme.recordColors +import io.github.prithvimk.gymtracker.ui.theme.tabular import io.github.prithvimk.gymtracker.ui.workout.RecordBadge import io.github.prithvimk.gymtracker.ui.workout.SetLabel import io.github.prithvimk.gymtracker.ui.workout.formatSetNumber -import io.github.prithvimk.gymtracker.ui.workout.recordText import io.github.prithvimk.gymtracker.ui.workout.text import java.time.Instant import java.time.LocalDate @@ -122,8 +137,11 @@ private fun ExerciseDetailContent(state: ExerciseDetailState.Loaded, modifier: M verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { ExerciseSubtitle(state.exercise) } + if (state.e1rm.isNotEmpty()) { + item { E1rmHero(state.e1rm, state.e1rmChange, Modifier.padding(top = 8.dp)) } + } item { SectionTitle(R.string.exercise_detail_records) } - item { RecordsCard(state.records) } + item { RecordTiles(state.records) } item { SectionTitle(R.string.exercise_detail_history) } if (state.sessions.isEmpty()) { item { MutedText(stringResource(R.string.exercise_detail_empty)) } @@ -177,35 +195,165 @@ private fun MutedText(text: String, modifier: Modifier = Modifier) { ) } -/** Each standing record with the day it was set, or a note that there are none yet. */ +/** + * The latest session's estimated 1RM in large type, how it moved over the last 30 days, and a + * sparkline of every session's. + */ +@Composable +private fun E1rmHero(points: List, change: Double?, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .fillMaxWidth() + .background(colors.primaryContainer, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.exercise_detail_e1rm), + style = MaterialTheme.typography.labelLarge, + color = colors.onPrimaryContainer, + ) + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = volumeText(points.last().kg), + style = MaterialTheme.typography.displaySmall.tabular(), + color = colors.onPrimaryContainer, + modifier = Modifier.weight(1f), + ) + if (points.size >= 2) { + Sparkline( + values = points.map { it.kg }, + color = colors.primary, + modifier = Modifier.width(SPARKLINE_WIDTH).height(SPARKLINE_HEIGHT).padding(bottom = 6.dp), + ) + } + } + if (change != null) E1rmChangeRow(change) + } +} + +/** "↑ +4.2 kg in 30 days", with an arrow only when it moved. */ +@Composable +private fun E1rmChangeRow(change: Double) { + val color = MaterialTheme.colorScheme.onPrimaryContainer + val text = + if (change == 0.0) { + pluralStringResource(R.plurals.exercise_detail_e1rm_no_change, E1RM_CHANGE_DAYS, E1RM_CHANGE_DAYS) + } else { + pluralStringResource( + R.plurals.exercise_detail_e1rm_change, + E1RM_CHANGE_DAYS, + signedVolumeText(change), + E1RM_CHANGE_DAYS, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (change != 0.0) { + Icon( + painter = painterResource(if (change > 0) R.drawable.ic_trending_up else R.drawable.ic_trending_down), + contentDescription = null, + tint = color, + modifier = Modifier.size(18.dp).padding(end = 4.dp), + ) + } + Text(text, style = MaterialTheme.typography.bodyMedium.tabular(), color = color) + } +} + +/** [values] as a line from the oldest on the left to the latest on the right, which gets a dot. */ @Composable -private fun RecordsCard(records: List) { +private fun Sparkline(values: List, color: Color, modifier: Modifier = Modifier) { + Canvas(modifier = modifier.clearAndSetSemantics {}) { + val low = values.min() + val span = (values.max() - low).takeIf { it > 0 } ?: 1.0 + val dot = 3.dp.toPx() + fun point(index: Int): Offset = Offset( + x = dot + (size.width - 2 * dot) * index / (values.size - 1), + y = dot + (size.height - 2 * dot) * (1 - ((values[index] - low) / span).toFloat()), + ) + val path = Path().apply { + moveTo(point(0).x, point(0).y) + for (index in 1 until values.size) lineTo(point(index).x, point(index).y) + } + drawPath(path, color, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + drawCircle(color, radius = dot, center = point(values.size - 1)) + } +} + +private val SPARKLINE_WIDTH = 120.dp +private val SPARKLINE_HEIGHT = 48.dp + +/** The standing records as a grid of tiles, two to a row, each with the day it was set. */ +@Composable +private fun RecordTiles(records: List) { if (records.isEmpty()) { MutedText(stringResource(R.string.exercise_detail_no_records)) return } - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - records.forEach { record -> - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(R.drawable.ic_trophy), - contentDescription = null, - tint = MaterialTheme.recordColors.record, - modifier = Modifier.size(18.dp), - ) - Text( - text = recordText(record.type, record.value, record.weightKg, record.reps), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f).padding(start = 8.dp), - ) - MutedText(formatDate(localDate(record.achievedAt), "dMMMy")) - } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + records.chunked(2).forEach { pair -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(IntrinsicSize.Min)) { + pair.forEach { RecordTile(it, Modifier.weight(1f).fillMaxHeight()) } + if (pair.size == 1) Spacer(Modifier.weight(1f)) } } } } +/** A record's value with a gold trophy, and what it is and when it was set under it. */ +@Composable +private fun RecordTile(record: RecordBest, modifier: Modifier = Modifier) { + val date = formatDate(localDate(record.achievedAt), "dMMM") + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(12.dp)) + .padding(12.dp) + .semantics(mergeDescendants = true) {}, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_trophy), + contentDescription = null, + tint = MaterialTheme.recordColors.record, + modifier = Modifier.size(16.dp), + ) + Text( + text = recordValueText(record), + style = MaterialTheme.typography.titleMedium.tabular(), + maxLines = 1, + modifier = Modifier.padding(start = 6.dp), + ) + } + Text( + text = stringResource(R.string.record_tile_label, stringResource(record.type.tileLabelRes), date), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** A record's number for its tile: "82.5 kg", "8 @ 82.5 kg", "104.5 kg". */ +@Composable +private fun recordValueText(record: RecordBest): String = when (record.type) { + PrType.WEIGHT -> weightText(record.value) + PrType.REPS_AT_WEIGHT -> stringResource( + R.string.record_tile_reps_at_weight, + record.reps, + weightText(record.weightKg) + ) + PrType.E1RM, PrType.VOLUME -> volumeText(record.value) +} + +private val PrType.tileLabelRes: Int + get() = when (this) { + PrType.WEIGHT -> R.string.record_tile_weight + PrType.REPS_AT_WEIGHT -> R.string.record_tile_reps + PrType.E1RM -> R.string.record_tile_e1rm + PrType.VOLUME -> R.string.record_tile_volume + } + @Composable private fun SessionCard(session: HistorySession) { Card(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseDetailViewModel.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseDetailViewModel.kt index 5f2014c..56305b1 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseDetailViewModel.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseDetailViewModel.kt @@ -22,15 +22,25 @@ sealed interface ExerciseDetailState { /** No exercise has the id, so there is nothing to show and the screen closes. */ data object Missing : ExerciseDetailState - /** [sessions] is newest first, and empty until a set of the exercise is completed. */ - data class Loaded(val exercise: Exercise, val records: List, val sessions: List) : - ExerciseDetailState + /** + * [sessions] is newest first, and empty until a set of the exercise is completed. [e1rm] is the best + * estimated 1RM of each session, oldest first, and [e1rmChange] how the latest moved over the last + * 30 days, if there is a session that old. + */ + data class Loaded( + val exercise: Exercise, + val records: List, + val sessions: List, + val e1rm: List = emptyList(), + val e1rmChange: Double? = null, + ) : ExerciseDetailState } class ExerciseDetailViewModel( savedStateHandle: SavedStateHandle, exerciseRepository: ExerciseRepository, workoutRepository: WorkoutRepository, + now: () -> Long = System::currentTimeMillis, ) : ViewModel() { private val exerciseId: String = checkNotNull(savedStateHandle.get(EXERCISE_ID_ARG)) @@ -45,7 +55,14 @@ class ExerciseDetailViewModel( if (exercise == null) { ExerciseDetailState.Missing } else { - ExerciseDetailState.Loaded(exercise, standingRecords(records, rows), historySessions(rows, records)) + val points = e1rmPoints(rows) + ExerciseDetailState.Loaded( + exercise = exercise, + records = standingRecords(records, rows), + sessions = historySessions(rows, records), + e1rm = points, + e1rmChange = e1rmChange(points, now()), + ) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), ExerciseDetailState.Loading) diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseFilter.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseFilter.kt index 8cda01a..e6c2e3f 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseFilter.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseFilter.kt @@ -1,11 +1,13 @@ package io.github.prithvimk.gymtracker.ui.exercises import io.github.prithvimk.gymtracker.data.db.entity.Exercise +import io.github.prithvimk.gymtracker.domain.model.Equipment import io.github.prithvimk.gymtracker.domain.model.MuscleGroup data class ExerciseFilter( val query: String = "", val muscleGroup: MuscleGroup? = null, + val equipment: Equipment? = null, val showArchived: Boolean = false, ) @@ -17,7 +19,8 @@ fun List.filteredBy(criteria: ExerciseFilter): List { return filter { exercise -> val visible = criteria.showArchived || !exercise.archived val inGroup = criteria.muscleGroup == null || exercise.muscleGroup == criteria.muscleGroup + val withEquipment = criteria.equipment == null || exercise.equipment == criteria.equipment val matches = words.all { exercise.name.contains(it, ignoreCase = true) } - visible && inGroup && matches + visible && inGroup && withEquipment && matches } } diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseHistory.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseHistory.kt index 69c9159..934e1f5 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseHistory.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseHistory.kt @@ -3,6 +3,8 @@ package io.github.prithvimk.gymtracker.ui.exercises import io.github.prithvimk.gymtracker.data.db.dao.ExerciseHistoryRow import io.github.prithvimk.gymtracker.data.db.entity.PersonalRecord import io.github.prithvimk.gymtracker.data.db.entity.WorkoutSet +import io.github.prithvimk.gymtracker.domain.countsForRecords +import io.github.prithvimk.gymtracker.domain.estimatedOneRepMaxKg import io.github.prithvimk.gymtracker.domain.model.PrType import io.github.prithvimk.gymtracker.ui.workout.SetLabel import io.github.prithvimk.gymtracker.ui.workout.setLabels @@ -41,6 +43,39 @@ fun standingRecords(records: List, rows: List): List = rows + .groupBy { it.workoutId } + .mapNotNull { (_, sessionRows) -> + val best = sessionRows + .filter { countsForRecords(it.set) } + .mapNotNull { estimatedOneRepMaxKg(it.set.weightKg, it.set.reps) } + .maxOrNull() + best?.let { E1rmPoint(sessionRows.first().workoutStartedAt, it) } + }.sortedBy { it.startedAt } + +/** + * How the latest point compares with the last one at least [days] days before [now], or null if there + * is no point that old, so the change always spans the whole period. + */ +fun e1rmChange(points: List, now: Long, days: Int = E1RM_CHANGE_DAYS): Double? { + val latest = points.lastOrNull() ?: return null + val cutoff = now - days * MILLIS_PER_DAY + val baseline = points.lastOrNull { it.startedAt <= cutoff } ?: return null + return if (baseline == latest) null else latest.kg - baseline.kg +} + +/** The period the exercise detail's e1RM change covers. */ +const val E1RM_CHANGE_DAYS = 30 + +private const val MILLIS_PER_DAY = 86_400_000L + /** [rows] grouped by workout, keeping their order, with each set's label and records. */ fun historySessions(rows: List, records: List): List { val typesBySet = records.groupBy({ it.setId }, { it.type }) diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExercisesScreen.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExercisesScreen.kt index b69f7ad..bea56eb 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExercisesScreen.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExercisesScreen.kt @@ -16,8 +16,11 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip @@ -25,13 +28,19 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -39,9 +48,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import io.github.prithvimk.gymtracker.R import io.github.prithvimk.gymtracker.data.db.entity.Exercise +import io.github.prithvimk.gymtracker.domain.LastTopSet import io.github.prithvimk.gymtracker.domain.model.Equipment import io.github.prithvimk.gymtracker.domain.model.MuscleGroup +import io.github.prithvimk.gymtracker.ui.format.relativeDayText +import io.github.prithvimk.gymtracker.ui.format.topSetText import io.github.prithvimk.gymtracker.ui.theme.GymTrackerTheme +import io.github.prithvimk.gymtracker.ui.theme.tabular @Composable fun ExercisesRoute( @@ -57,6 +70,7 @@ fun ExercisesRoute( list = list, onQueryChange = viewModel::onQueryChange, onMuscleGroupSelect = viewModel::onMuscleGroupSelect, + onEquipmentSelect = viewModel::onEquipmentSelect, onShowArchivedChange = viewModel::onShowArchivedChange, onExerciseClick = onExerciseClick, onCreateClick = viewModel::onCreateClick, @@ -76,6 +90,7 @@ fun ExercisesScreen( onExerciseClick: (Exercise) -> Unit, onCreateClick: () -> Unit, modifier: Modifier = Modifier, + onEquipmentSelect: (Equipment?) -> Unit = {}, ) { Box(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { @@ -84,8 +99,12 @@ fun ExercisesScreen( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp), ) - SearchField(query = filter.query, onQueryChange = onQueryChange) - MuscleGroupChips(selected = filter.muscleGroup, onSelect = onMuscleGroupSelect) + ExerciseSearchBar(query = filter.query, count = list.libraryCount, onQueryChange = onQueryChange) + FilterChips( + filter = filter, + onMuscleGroupSelect = onMuscleGroupSelect, + onEquipmentSelect = onEquipmentSelect + ) if (list.hasArchived || filter.showArchived) { ShowArchivedRow(checked = filter.showArchived, onCheckedChange = onShowArchivedChange) } @@ -100,48 +119,108 @@ fun ExercisesScreen( } } +/** A Material 3 search bar, always collapsed: the list under it is the results. */ @Composable -private fun SearchField(query: String, onQueryChange: (String) -> Unit) { - OutlinedTextField( - value = query, - onValueChange = onQueryChange, +private fun ExerciseSearchBar(query: String, count: Int, onQueryChange: (String) -> Unit) { + val focusManager = LocalFocusManager.current + Surface( + shape = SearchBarDefaults.inputFieldShape, + color = SearchBarDefaults.colors().containerColor, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - placeholder = { Text(stringResource(R.string.exercises_search)) }, - leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, - trailingIcon = { - if (query.isNotEmpty()) { - IconButton(onClick = { onQueryChange("") }) { - Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.exercises_clear_search)) + ) { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { focusManager.clearFocus() }, + expanded = false, + onExpandedChange = {}, + placeholder = { + Text( + if (count > 0) { + pluralStringResource(R.plurals.exercises_search_count, count, count) + } else { + stringResource(R.string.exercises_search) + }, + ) + }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.exercises_clear_search)) + } } - } - }, - singleLine = true, - ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } } +/** + * The Equipment chip, which opens a menu, then All and a chip per muscle group. Equipment comes first + * so it is on screen without scrolling the row. + */ @Composable -private fun MuscleGroupChips(selected: MuscleGroup?, onSelect: (MuscleGroup?) -> Unit) { +private fun FilterChips( + filter: ExerciseFilter, + onMuscleGroupSelect: (MuscleGroup?) -> Unit, + onEquipmentSelect: (Equipment?) -> Unit, +) { + val selected = filter.muscleGroup LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + item { EquipmentChip(filter.equipment, onEquipmentSelect) } item { FilterChip( selected = selected == null, - onClick = { onSelect(null) }, + onClick = { onMuscleGroupSelect(null) }, label = { Text(stringResource(R.string.exercises_filter_all)) }, ) } items(MuscleGroup.entries) { group -> FilterChip( selected = selected == group, - onClick = { onSelect(if (selected == group) null else group) }, + onClick = { onMuscleGroupSelect(if (selected == group) null else group) }, label = { Text(stringResource(group.labelRes)) }, ) } } } +/** "Equipment ▾", or the equipment picked, with a menu of every kind and Any equipment. */ +@Composable +private fun EquipmentChip(equipment: Equipment?, onSelect: (Equipment?) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + FilterChip( + selected = equipment != null, + onClick = { open = true }, + label = { Text(stringResource(equipment?.labelRes ?: R.string.exercises_equipment)) }, + trailingIcon = { Icon(Icons.Filled.ArrowDropDown, contentDescription = null) }, + ) + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.exercises_any_equipment)) }, + onClick = { + open = false + onSelect(null) + }, + ) + Equipment.entries.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes)) }, + onClick = { + open = false + onSelect(option) + }, + ) + } + } + } +} + @Composable private fun ShowArchivedRow(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { Row( @@ -178,15 +257,18 @@ private fun ExerciseList(list: ExerciseListState, onClick: (Exercise) -> Unit, m contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp), ) { items(list.exercises, key = { it.id }) { exercise -> - ExerciseRow(exercise = exercise, onClick = { onClick(exercise) }) + ExerciseRow(exercise = exercise, last = list.lastTopSets[exercise.id], onClick = { + onClick(exercise) + }) } } } } } +/** An exercise with its muscle group and equipment, and for one you've trained, its [last] top set and when. */ @Composable -private fun ExerciseRow(exercise: Exercise, onClick: () -> Unit) { +private fun ExerciseRow(exercise: Exercise, last: LastTopSet?, onClick: () -> Unit) { val muscle = stringResource(exercise.muscleGroup.labelRes) val equipment = stringResource(exercise.equipment.labelRes) val tagRes = @@ -197,7 +279,23 @@ private fun ExerciseRow(exercise: Exercise, onClick: () -> Unit) { } ListItem( headlineContent = { Text(exercise.name) }, - supportingContent = { Text(stringResource(R.string.exercise_subtitle, muscle, equipment)) }, + supportingContent = { + Column { + Text(stringResource(R.string.exercise_subtitle, muscle, equipment)) + if (last != null) { + Text( + text = + stringResource( + R.string.exercises_last_top_set, + topSetText(last.topSet), + relativeDayText(last.startedAt), + ), + style = MaterialTheme.typography.bodyMedium.tabular(), + color = MaterialTheme.colorScheme.primary, + ) + } + } + }, trailingContent = { if (tagRes != null) { Text( diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExercisesViewModel.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExercisesViewModel.kt index e6b63ec..1b49c16 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExercisesViewModel.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/exercises/ExercisesViewModel.kt @@ -8,6 +8,9 @@ import androidx.lifecycle.viewmodel.viewModelFactory import io.github.prithvimk.gymtracker.GymTrackerApp import io.github.prithvimk.gymtracker.data.db.entity.Exercise import io.github.prithvimk.gymtracker.data.repository.ExerciseRepository +import io.github.prithvimk.gymtracker.data.repository.WorkoutRepository +import io.github.prithvimk.gymtracker.domain.LastTopSet +import io.github.prithvimk.gymtracker.domain.model.Equipment import io.github.prithvimk.gymtracker.domain.model.MuscleGroup import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -17,13 +20,19 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +/** + * The exercises the filter lets through. [libraryCount] counts the exercises that aren't archived, for + * the search bar; [lastTopSets] holds each trained exercise's last top set, by exercise id. + */ data class ExerciseListState( val exercises: List = emptyList(), val isLoading: Boolean = true, val hasArchived: Boolean = false, + val libraryCount: Int = 0, + val lastTopSets: Map = emptyMap(), ) -class ExercisesViewModel(repository: ExerciseRepository) : ViewModel() { +class ExercisesViewModel(repository: ExerciseRepository, workoutRepository: WorkoutRepository) : ViewModel() { // Text field state lives in plain StateFlows so typing never waits on the database. private val _filter = MutableStateFlow(ExerciseFilter()) val filter: StateFlow = _filter.asStateFlow() @@ -31,11 +40,13 @@ class ExercisesViewModel(repository: ExerciseRepository) : ViewModel() { val editor = ExerciseEditor(repository, viewModelScope) private val listFlow = - combine(repository.observeExercises(), _filter) { all, criteria -> + combine(repository.observeExercises(), workoutRepository.observeLastTopSets(), _filter) { all, tops, criteria -> ExerciseListState( exercises = all.filteredBy(criteria), isLoading = false, hasArchived = all.any { it.archived }, + libraryCount = all.count { !it.archived }, + lastTopSets = tops, ) } @@ -50,6 +61,10 @@ class ExercisesViewModel(repository: ExerciseRepository) : ViewModel() { _filter.update { it.copy(muscleGroup = group) } } + fun onEquipmentSelect(equipment: Equipment?) { + _filter.update { it.copy(equipment = equipment) } + } + fun onShowArchivedChange(show: Boolean) { _filter.update { it.copy(showArchived = show) } } @@ -65,7 +80,7 @@ class ExercisesViewModel(repository: ExerciseRepository) : ViewModel() { viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as GymTrackerApp - ExercisesViewModel(app.container.exerciseRepository) + ExercisesViewModel(app.container.exerciseRepository, app.container.workoutRepository) } } } diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/format/Formats.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/format/Formats.kt index 46220d4..4a209c7 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/format/Formats.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/format/Formats.kt @@ -64,6 +64,18 @@ fun weightText(kg: Double): String = stringResource(R.string.weight_value, forma @ReadOnlyComposable fun volumeText(kg: Double): String = stringResource(R.string.weight_value, formatVolume(kg, currentLocale())) +/** A change in a volume or estimate with its sign and unit, e.g. "+4.2 kg" or "−2.5 kg". */ +@Composable +@ReadOnlyComposable +fun signedVolumeText(kg: Double): String { + val text = volumeText(kotlin.math.abs(kg)) + return when { + kg > 0 -> "+$text" + kg < 0 -> "\u2212$text" + else -> text + } +} + /** A weight's number alone, for compact columns where the unit is in the header, e.g. "80" of "80 × 8". */ @Composable @ReadOnlyComposable diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/format/TopSetText.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/format/TopSetText.kt new file mode 100644 index 0000000..5600558 --- /dev/null +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/format/TopSetText.kt @@ -0,0 +1,15 @@ +package io.github.prithvimk.gymtracker.ui.format + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import io.github.prithvimk.gymtracker.R +import io.github.prithvimk.gymtracker.domain.TopSet + +/** A top set as "82.5 kg × 8", or "12 reps" for a bodyweight set. */ +@Composable +fun topSetText(top: TopSet): String = if (top.weightKg > 0) { + stringResource(R.string.exercise_detail_set, weightText(top.weightKg), top.reps) +} else { + pluralStringResource(R.plurals.exercise_detail_set_reps, top.reps, top.reps) +} diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryCalendar.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryCalendar.kt index 7cfd6e3..1a792e2 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryCalendar.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryCalendar.kt @@ -6,6 +6,7 @@ import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId +import kotlin.math.ceil /** * One month of the History calendar. [days] fills whole weeks starting on [firstDayOfWeek], with @@ -25,6 +26,9 @@ data class HistoryMonth( get() = selectedDay?.let { workouts[it].orEmpty() } ?: workouts.values.flatten().sortedByDescending { it.startedAt } + /** Each trained day's volume step, from 1 to [VOLUME_LEVELS]; see [volumeLevels]. */ + val volumeLevels: Map by lazy { volumeLevels(workouts) } + /** Whether a later month can be shown: not past the current one. */ val hasNext: Boolean get() = month < YearMonth.from(today) } @@ -49,6 +53,21 @@ fun workoutsByDay( .groupBy({ it.first }, { it.second }) .mapValues { (_, rows) -> rows.sortedByDescending { it.startedAt } } +/** How many steps the calendar tints trained days in. */ +const val VOLUME_LEVELS = 3 + +/** + * Each day's step by its total volume: which third of the busiest day's volume it reaches, from 1 to + * [VOLUME_LEVELS]. A trained day that moved no weight, such as bodyweight work, is step 1. + */ +fun volumeLevels(workouts: Map>): Map { + val volumes = workouts.mapValues { (_, rows) -> rows.sumOf { it.volumeKg } } + val busiest = volumes.values.maxOrNull() ?: return emptyMap() + return volumes.mapValues { (_, volume) -> + if (busiest <= 0.0) 1 else ceil(VOLUME_LEVELS * volume / busiest).toInt().coerceIn(1, VOLUME_LEVELS) + } +} + /** The weekdays in calendar order from [firstDayOfWeek], for the header row. */ fun weekdays(firstDayOfWeek: DayOfWeek): List = List(DAYS_IN_WEEK) { firstDayOfWeek.plus(it.toLong()) } diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryScreen.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryScreen.kt index 4122c7e..df26751 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryScreen.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -20,14 +21,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.DateRange import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -35,15 +35,22 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -125,12 +132,7 @@ private fun HistoryContent( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - item(key = "header") { - Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(bottom = 8.dp)) { - Text(text = stringResource(R.string.nav_history), style = MaterialTheme.typography.headlineMedium) - ViewSwitch(state.view, calendarActions.onViewChange) - } - } + item(key = "header") { HistoryHeader(state.view, calendarActions.onViewChange) } val calendar = state.calendar if (state.view == HistoryView.CALENDAR && calendar != null) { item(key = "calendar") { MonthCalendar(calendar, calendarActions) } @@ -144,35 +146,36 @@ private fun HistoryContent( ) } } - items(calendar.listed, key = { it.id }) { workout -> WorkoutCard(workout, { onWorkoutClick(workout.id) }) } + items(calendar.listed, key = { it.id }) { workout -> + WorkoutCard(workout, state.exerciseNames[workout.id].orEmpty(), { onWorkoutClick(workout.id) }) + } } else { state.weeks.forEach { week -> item(key = "week-${week.weekStart}") { WeekHeader(week) } items(week.workouts, key = { it.id }) { workout -> - WorkoutCard(workout, { onWorkoutClick(workout.id) }) + WorkoutCard(workout, state.exerciseNames[workout.id].orEmpty(), { onWorkoutClick(workout.id) }) } } } } } +/** The tab's title, with an icon that switches between the list and the calendar. */ @Composable -private fun ViewSwitch(view: HistoryView, onViewChange: (HistoryView) -> Unit) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - HistoryView.entries.forEachIndexed { index, option -> - SegmentedButton( - selected = option == view, - onClick = { onViewChange(option) }, - shape = SegmentedButtonDefaults.itemShape(index, HistoryView.entries.size), - ) { - Text( - stringResource( - when (option) { - HistoryView.LIST -> R.string.history_view_list - HistoryView.CALENDAR -> R.string.history_view_calendar - }, - ), - ) +private fun HistoryHeader(view: HistoryView, onViewChange: (HistoryView) -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Text( + text = stringResource(R.string.nav_history), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.weight(1f).semantics { heading() }, + ) + if (view == HistoryView.LIST) { + IconButton(onClick = { onViewChange(HistoryView.CALENDAR) }) { + Icon(Icons.Filled.DateRange, contentDescription = stringResource(R.string.history_show_calendar)) + } + } else { + IconButton(onClick = { onViewChange(HistoryView.LIST) }) { + Icon(Icons.AutoMirrored.Filled.List, contentDescription = stringResource(R.string.history_show_list)) } } } @@ -223,6 +226,7 @@ private fun MonthCalendar(calendar: HistoryMonth, actions: CalendarActions) { DayCell( day = day, count = calendar.workouts[day].orEmpty().size, + level = calendar.volumeLevels[day] ?: 0, isToday = day == calendar.today, selected = day == calendar.selectedDay, onClick = { actions.onDayClick(day) }, @@ -232,12 +236,61 @@ private fun MonthCalendar(calendar: HistoryMonth, actions: CalendarActions) { } } } + VolumeLegend(Modifier.align(Alignment.End).padding(top = 4.dp, end = 8.dp, bottom = 4.dp)) } } } +/** "Less ▢▢▢ More volume": what the tints on trained days mean. */ @Composable -private fun DayCell(day: LocalDate, count: Int, isToday: Boolean, selected: Boolean, onClick: () -> Unit) { +private fun VolumeLegend(modifier: Modifier = Modifier) { + val less = stringResource(R.string.history_legend_less) + val more = stringResource(R.string.history_legend_more) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier.semantics(mergeDescendants = true) {}, + ) { + Text(less, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + (1..VOLUME_LEVELS).forEach { level -> + Box(Modifier.size(12.dp).clip(CircleShape).background(volumeTint(level))) + } + Text(more, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** + * A trained day's fill by volume [level]: half-way to the primary container, the container itself, + * then half-way on to primary, which is kept for the picked day. + */ +@Composable +private fun volumeTint(level: Int): Color { + val colors = MaterialTheme.colorScheme + return when (level) { + 1 -> lerp(colors.surfaceContainerHigh, colors.primaryContainer, HALF) + 2 -> colors.primaryContainer + else -> lerp(colors.primaryContainer, colors.primary, HALF) + } +} + +/** Dark or light text, whichever reads better on [background]. */ +@Composable +private fun contentOn(background: Color): Color { + val colors = MaterialTheme.colorScheme + val dark = if (colors.onSurface.luminance() < colors.surface.luminance()) colors.onSurface else colors.surface + val light = if (dark == colors.onSurface) colors.surface else colors.onSurface + return if (background.luminance() > LIGHT_BACKGROUND_LUMINANCE) dark else light +} + +private const val HALF = 0.5f +private const val LIGHT_BACKGROUND_LUMINANCE = 0.3f + +/** + * One day of the month. A trained day is tinted by its volume [level] and can be picked; the picked + * day is filled with primary, and today keeps its ring whatever else it is. + */ +@Composable +private fun DayCell(day: LocalDate, count: Int, level: Int, isToday: Boolean, selected: Boolean, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val trained = count > 0 val description = @@ -246,16 +299,27 @@ private fun DayCell(day: LocalDate, count: Int, isToday: Boolean, selected: Bool } else { formatDate(day, "EEEEdMMMM") } + val fill = + when { + selected -> colors.primary + trained -> volumeTint(level) + else -> Color.Transparent + } + val textColor = + when { + selected -> colors.onPrimary + trained -> contentOn(fill) + else -> colors.onSurface + } Box( contentAlignment = Alignment.Center, modifier = Modifier - .padding(3.dp) + .padding(2.dp) .fillMaxSize() - .then(if (selected) Modifier.border(2.dp, colors.onSurface, CircleShape) else Modifier) - .padding(if (selected) 3.dp else 0.dp) + .then(if (isToday) Modifier.border(2.dp, colors.primary, CircleShape) else Modifier) + .padding(4.dp) .clip(CircleShape) - .then(if (trained) Modifier.background(colors.primary) else Modifier) - .then(if (isToday && !trained) Modifier.border(1.dp, colors.primary, CircleShape) else Modifier) + .background(fill) .then(if (trained) Modifier.clickable(onClick = onClick) else Modifier) .clearAndSetSemantics { contentDescription = description @@ -266,7 +330,7 @@ private fun DayCell(day: LocalDate, count: Int, isToday: Boolean, selected: Bool text = day.dayOfMonth.toString(), style = MaterialTheme.typography.bodyMedium, fontWeight = if (isToday || trained) FontWeight.Bold else null, - color = if (trained) colors.onPrimary else colors.onSurface, + color = textColor, ) } } @@ -330,36 +394,48 @@ private fun WeekHeader(week: HistoryWeek) { } } +/** + * A finished workout: when it started, with a gold chip if it set records, its routine, its duration, + * sets and volume, and the first few of its [exerciseNames]. + */ @Composable -internal fun WorkoutCard(workout: WorkoutHistoryRow, onClick: () -> Unit, modifier: Modifier = Modifier) { +internal fun WorkoutCard( + workout: WorkoutHistoryRow, + exerciseNames: List, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { Card(onClick = onClick, modifier = modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 28.dp)) { + Text( + text = formatStartTime(workout.startedAt), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (workout.recordCount > 0) { + RecordChip( + pluralStringResource( + R.plurals.history_workout_records, + workout.recordCount, + workout.recordCount + ), + ) + } + } Text( text = workout.routineName ?: stringResource(R.string.history_workout_untitled), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleLarge, ) - Text( - text = formatStartTime(workout.startedAt), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = - stringResource( - R.string.history_workout_stats, - durationText(workout.endedAt - workout.startedAt), - pluralStringResource(R.plurals.history_workout_sets, workout.setCount, workout.setCount), - volumeText(workout.volumeKg), - ), - style = MaterialTheme.typography.bodyMedium.tabular(), - ) - if (workout.recordCount > 0) { - RecordChip( - text = pluralStringResource( - R.plurals.history_workout_records, - workout.recordCount, - workout.recordCount - ), + WorkoutStats(workout) + if (exerciseNames.isNotEmpty()) { + Text( + text = exercisesText(exerciseNames), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp), ) } @@ -367,6 +443,39 @@ internal fun WorkoutCard(workout: WorkoutHistoryRow, onClick: () -> Unit, modifi } } +/** Duration with a timer icon, then sets and volume, on one row. */ +@Composable +private fun WorkoutStats(workout: WorkoutHistoryRow) { + val style = MaterialTheme.typography.bodyLarge.tabular() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_rest_timer), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Text( + durationText(workout.endedAt - workout.startedAt), + style = style, + modifier = Modifier.padding(start = 4.dp) + ) + } + Text(pluralStringResource(R.plurals.history_workout_sets, workout.setCount, workout.setCount), style = style) + Text(volumeText(workout.volumeKg), style = style) + } +} + +/** The first [CARD_EXERCISES] names, then how many more: "Bench Press, Overhead Press, Dip +2". */ +@Composable +private fun exercisesText(names: List): String { + val shown = names.take(CARD_EXERCISES).joinToString() + val more = names.size - CARD_EXERCISES + return if (more > 0) stringResource(R.string.history_workout_more_exercises, shown, more) else shown +} + +private const val CARD_EXERCISES = 3 + @Composable private fun EmptyHistory(modifier: Modifier = Modifier) { Column( diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryViewModel.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryViewModel.kt index 718b159..f90a7cb 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/HistoryViewModel.kt @@ -31,12 +31,13 @@ sealed interface HistoryUiState { /** * Finished workouts by week, newest first, and the calendar's month. [weeks] is empty until the - * first workout is finished. + * first workout is finished. [exerciseNames] holds each workout's exercises in order, by workout id. */ data class Loaded( val weeks: List, val view: HistoryView = HistoryView.LIST, val calendar: HistoryMonth? = null, + val exerciseNames: Map> = emptyMap(), ) : HistoryUiState } @@ -55,7 +56,12 @@ class HistoryViewModel( // Zone, week start and today are read on every change, so a list kept open over midnight or a // time zone change still groups the next workout right. val state: StateFlow = - combine(workoutRepository.observeHistory(), view, pick) { rows, currentView, currentPick -> + combine( + workoutRepository.observeHistory(), + workoutRepository.observeHistoryExerciseNames(), + view, + pick, + ) { rows, names, currentView, currentPick -> val currentZone = zone() val weekStart = firstDayOfWeek() val currentDay = today(currentZone) @@ -72,6 +78,7 @@ class HistoryViewModel( today = currentDay, selectedDay = currentPick.day, ), + exerciseNames = names, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), HistoryUiState.Loading) diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/WorkoutDetailScreen.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/WorkoutDetailScreen.kt index 8afb418..102d43e 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/WorkoutDetailScreen.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/history/WorkoutDetailScreen.kt @@ -3,10 +3,12 @@ package io.github.prithvimk.gymtracker.ui.history import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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 @@ -14,12 +16,16 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,7 +36,9 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource @@ -41,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import io.github.prithvimk.gymtracker.R import io.github.prithvimk.gymtracker.domain.WorkoutSummary +import io.github.prithvimk.gymtracker.ui.components.StatTile import io.github.prithvimk.gymtracker.ui.exercises.HistorySetRow import io.github.prithvimk.gymtracker.ui.exercises.labelRes import io.github.prithvimk.gymtracker.ui.format.durationText @@ -55,6 +64,7 @@ import io.github.prithvimk.gymtracker.ui.workout.SessionEditorDialogs @Composable fun WorkoutDetailRoute( onBack: () -> Unit, + onExerciseClick: (exerciseId: String) -> Unit, modifier: Modifier = Modifier, viewModel: WorkoutDetailViewModel = viewModel(factory = WorkoutDetailViewModel.factory), ) { @@ -89,6 +99,7 @@ fun WorkoutDetailRoute( onEdit = viewModel::onEditClick, onDone = viewModel::onDoneClick, onDelete = viewModel::onDeleteClick, + onExerciseClick = onExerciseClick, editActions = editActions, keypad = keypad, modifier = modifier, @@ -125,6 +136,7 @@ internal fun WorkoutDetailScreen( editActions: SessionEditActions, modifier: Modifier = Modifier, keypad: KeypadState? = null, + onExerciseClick: (exerciseId: String) -> Unit = {}, ) { val loaded = state as? WorkoutDetailState.Loaded val editing = loaded?.editing == true @@ -162,8 +174,25 @@ internal fun WorkoutDetailScreen( IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.workout_detail_edit)) } - IconButton(onClick = onDelete) { - Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.workout_detail_delete)) + // Delete sits in the menu, a deliberate tap away from Edit. + var menuOpen by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon( + Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.workout_detail_more) + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.workout_detail_delete)) }, + leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, + onClick = { + menuOpen = false + onDelete() + }, + ) + } } } }, @@ -186,13 +215,17 @@ internal fun WorkoutDetailScreen( alwaysShowNotes = true, ) - else -> WorkoutDetailContent(loaded, Modifier.weight(1f)) + else -> WorkoutDetailContent(loaded, onExerciseClick, Modifier.weight(1f)) } } } @Composable -private fun WorkoutDetailContent(state: WorkoutDetailState.Loaded, modifier: Modifier = Modifier,) { +private fun WorkoutDetailContent( + state: WorkoutDetailState.Loaded, + onExerciseClick: (exerciseId: String) -> Unit, + modifier: Modifier = Modifier, +) { val workout = state.session.workout LazyColumn( modifier = modifier.fillMaxSize(), @@ -200,9 +233,9 @@ private fun WorkoutDetailContent(state: WorkoutDetailState.Loaded, modifier: Mod verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { MutedText(formatStartTime(workout.startedAt)) - Text(text = statsText(state.summary), style = MaterialTheme.typography.bodyLarge) + SummaryTiles(state.summary) } } if (state.records.isNotEmpty()) { @@ -220,46 +253,82 @@ private fun WorkoutDetailContent(state: WorkoutDetailState.Loaded, modifier: Mod } items(state.exercises, key = { it.entry.id }) { exercise -> val notDone = state.session.sets[exercise.entry.id].orEmpty().size - exercise.sets.size - DoneExerciseCard(exercise, notDone) + DoneExerciseCard(exercise, notDone, onClick = { onExerciseClick(exercise.entry.exerciseId) }) } } } -/** One exercise's done sets, with its note and how many sets were left undone. */ +/** + * One exercise's done sets, with its note and how many planned sets were skipped. An exercise with + * nothing done is a single "Skipped" line. Its name opens the exercise's history. + */ @Composable -private fun DoneExerciseCard(exercise: DoneExercise, notDone: Int) { +private fun DoneExerciseCard(exercise: DoneExercise, notDone: Int, onClick: () -> Unit) { val entry = exercise.entry + val skipped = exercise.sets.isEmpty() Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(entry.name, style = MaterialTheme.typography.titleMedium) - MutedText( - stringResource( - R.string.exercise_subtitle, - stringResource(entry.muscleGroup.labelRes), - stringResource(entry.equipment.labelRes), - ), + val openLabel = stringResource(R.string.workout_detail_open_exercise) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClickLabel = openLabel, onClick = onClick) + .padding(start = 16.dp, end = 12.dp, top = if (skipped) 12.dp else 16.dp, bottom = 12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(entry.name, style = MaterialTheme.typography.titleMedium) + if (!skipped) { + MutedText( + stringResource( + R.string.exercise_subtitle, + stringResource(entry.muscleGroup.labelRes), + stringResource(entry.equipment.labelRes), + ), + ) + } + } + if (skipped) MutedText(stringResource(R.string.workout_detail_skipped)) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp), ) - if (entry.notes.isNotBlank()) Text(entry.notes, style = MaterialTheme.typography.bodyMedium) + } + if (!skipped) { Column( - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - if (exercise.sets.isEmpty()) MutedText(stringResource(R.string.workout_detail_no_sets)) + if (entry.notes.isNotBlank()) { + Text( + entry.notes, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 4.dp) + ) + } exercise.sets.forEach { HistorySetRow(it) } - if (notDone > 0) MutedText(pluralStringResource(R.plurals.workout_detail_not_done, notDone, notDone)) + if (notDone > 0) { + MutedText(pluralStringResource(R.plurals.workout_detail_not_done, notDone, notDone)) + } } } } } -/** "1 h 2 min · 18 sets · 8,420 kg", the same as the workout's card in History. */ +/** Duration, volume and sets as tiles, the same as on the finish summary. */ @Composable -private fun statsText(summary: WorkoutSummary): String = stringResource( - R.string.history_workout_stats, - durationText(summary.durationMillis), - pluralStringResource(R.plurals.history_workout_sets, summary.setCount, summary.setCount), - volumeText(summary.volumeKg), -) +private fun SummaryTiles(summary: WorkoutSummary) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatTile( + durationText(summary.durationMillis), + stringResource(R.string.workout_summary_duration), + Modifier.weight(1f) + ) + StatTile(volumeText(summary.volumeKg), stringResource(R.string.workout_summary_volume), Modifier.weight(1.3f)) + StatTile(summary.setCount.toString(), stringResource(R.string.workout_summary_sets), Modifier.weight(0.7f)) + } +} @Composable private fun MutedText(text: String, modifier: Modifier = Modifier) { diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/routines/RoutineEditorScreen.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/routines/RoutineEditorScreen.kt index e2ad2bd..a46ec54 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/routines/RoutineEditorScreen.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/routines/RoutineEditorScreen.kt @@ -57,6 +57,7 @@ import io.github.prithvimk.gymtracker.ui.components.restText import io.github.prithvimk.gymtracker.ui.exercises.labelRes import io.github.prithvimk.gymtracker.ui.theme.GymTrackerTheme import io.github.prithvimk.gymtracker.ui.workout.ExercisePickerSheet +import io.github.prithvimk.gymtracker.ui.workout.PickerActions @Composable fun RoutineEditorRoute( @@ -92,10 +93,14 @@ fun RoutineEditorRoute( picker?.let { pickerState -> ExercisePickerSheet( state = pickerState, - onQueryChange = viewModel::onPickerQueryChange, - onToggle = viewModel::onPickerToggle, - onConfirm = viewModel::onPickerConfirm, - onDismiss = viewModel::onPickerDismiss, + actions = + PickerActions( + onQueryChange = viewModel::onPickerQueryChange, + onChipSelect = viewModel::onPickerChipSelect, + onToggle = viewModel::onPickerToggle, + onConfirm = viewModel::onPickerConfirm, + onDismiss = viewModel::onPickerDismiss, + ), ) } diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/routines/RoutineEditorViewModel.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/routines/RoutineEditorViewModel.kt index b45764d..0669587 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/routines/RoutineEditorViewModel.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/routines/RoutineEditorViewModel.kt @@ -12,11 +12,12 @@ import io.github.prithvimk.gymtracker.data.repository.ExerciseRepository import io.github.prithvimk.gymtracker.data.repository.RoutineExerciseInput import io.github.prithvimk.gymtracker.data.repository.RoutineRepository import io.github.prithvimk.gymtracker.data.repository.RoutineSaveResult +import io.github.prithvimk.gymtracker.data.repository.WorkoutRepository import io.github.prithvimk.gymtracker.domain.ExerciseTargets -import io.github.prithvimk.gymtracker.ui.exercises.ExerciseFilter -import io.github.prithvimk.gymtracker.ui.exercises.filteredBy import io.github.prithvimk.gymtracker.ui.workout.ExercisePickerState +import io.github.prithvimk.gymtracker.ui.workout.PickerChip import io.github.prithvimk.gymtracker.ui.workout.toggled +import io.github.prithvimk.gymtracker.ui.workout.withLibrary import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -49,6 +50,7 @@ class RoutineEditorViewModel( savedStateHandle: SavedStateHandle, private val routineRepository: RoutineRepository, exerciseRepository: ExerciseRepository, + workoutRepository: WorkoutRepository, ) : ViewModel() { private val routineId: String? = savedStateHandle[ROUTINE_ID_ARG] @@ -60,8 +62,12 @@ class RoutineEditorViewModel( private val pickerInput = MutableStateFlow(null) val picker: StateFlow = - combine(pickerInput, exerciseRepository.observeExercises()) { input, all -> - input?.copy(options = all.filteredBy(ExerciseFilter(query = input.query))) + combine( + pickerInput, + exerciseRepository.observeExercises(), + workoutRepository.observeRecentExerciseIds(), + ) { input, all, recent -> + input?.withLibrary(all, recent) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) // Names and details of library exercises, for exercises added from the picker. @@ -141,6 +147,10 @@ class RoutineEditorViewModel( pickerInput.update { it?.copy(query = query) } } + fun onPickerChipSelect(chip: PickerChip) { + pickerInput.update { it?.copy(chip = chip) } + } + fun onPickerToggle(exerciseId: String) { pickerInput.update { it?.copy(selectedIds = it.selectedIds.toggled(exerciseId)) } } @@ -246,6 +256,7 @@ class RoutineEditorViewModel( createSavedStateHandle(), app.container.routineRepository, app.container.exerciseRepository, + app.container.workoutRepository, ) } } diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePicker.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePicker.kt new file mode 100644 index 0000000..c1b61e0 --- /dev/null +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePicker.kt @@ -0,0 +1,53 @@ +package io.github.prithvimk.gymtracker.ui.workout + +import io.github.prithvimk.gymtracker.data.db.entity.Exercise +import io.github.prithvimk.gymtracker.domain.model.MuscleGroup +import io.github.prithvimk.gymtracker.ui.exercises.ExerciseFilter +import io.github.prithvimk.gymtracker.ui.exercises.filteredBy + +/** A chip over the add-exercises list: what it narrows the list to. */ +sealed interface PickerChip { + /** Exercises from the last few finished workouts, most recent first. */ + data object Recent : PickerChip + + data object All : PickerChip + + data class Muscle(val group: MuscleGroup) : PickerChip +} + +/** + * The add-exercises sheet. [selectedIds] keeps tap order, which is the order the exercises are added + * in, and [picks] holds those exercises for the tray. [chip] is null until one is tapped, which means + * Recent if there are recent exercises and All if not. [options] is the list under the chips. + */ +data class ExercisePickerState( + val query: String = "", + val selectedIds: List = emptyList(), + val chip: PickerChip? = null, + val hasRecent: Boolean = false, + val options: List = emptyList(), + val picks: List = emptyList(), +) { + /** The chip that is on: the one tapped, or the default. */ + val shownChip: PickerChip get() = chip ?: if (hasRecent) PickerChip.Recent else PickerChip.All +} + +/** + * Fills in the sheet from the library [all] and the [recentIds] (most recent first). A search under + * Recent looks through the whole library, since the recent list is short; under a muscle group it + * stays within that group. Archived exercises are never offered. + */ +fun ExercisePickerState.withLibrary(all: List, recentIds: List): ExercisePickerState { + val byId = all.associateBy { it.id } + val recent = recentIds.distinct().mapNotNull { byId[it] }.filterNot { it.archived } + val resolved = copy(hasRecent = recent.isNotEmpty()) + val options = + when (val chip = resolved.shownChip) { + PickerChip.Recent -> + if (query.isBlank()) recent else all.filteredBy(ExerciseFilter(query = query)) + + PickerChip.All -> all.filteredBy(ExerciseFilter(query = query)) + is PickerChip.Muscle -> all.filteredBy(ExerciseFilter(query = query, muscleGroup = chip.group)) + } + return resolved.copy(options = options, picks = selectedIds.mapNotNull { byId[it] }) +} diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePickerSheet.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePickerSheet.kt index 6f6c40d..11978d1 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePickerSheet.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePickerSheet.kt @@ -2,112 +2,248 @@ package io.github.prithvimk.gymtracker.ui.workout -import androidx.compose.foundation.clickable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import io.github.prithvimk.gymtracker.R +import io.github.prithvimk.gymtracker.data.db.entity.Exercise +import io.github.prithvimk.gymtracker.domain.model.MuscleGroup import io.github.prithvimk.gymtracker.ui.exercises.labelRes +import io.github.prithvimk.gymtracker.ui.theme.tabular -/** Pick one or more exercises to add to the active workout. They are added in the order they were ticked. */ -@Composable -fun ExercisePickerSheet( - state: ExercisePickerState, - onQueryChange: (String) -> Unit, - onToggle: (String) -> Unit, - onConfirm: () -> Unit, - onDismiss: () -> Unit, +/** What the add-exercises sheet does. */ +class PickerActions( + val onQueryChange: (String) -> Unit, + val onChipSelect: (PickerChip) -> Unit, + val onToggle: (String) -> Unit, + val onConfirm: () -> Unit, + val onDismiss: () -> Unit, ) { + companion object { + val None = PickerActions({}, {}, {}, {}, {}) + } +} + +/** Pick one or more exercises to add. They are added in the order they were ticked, which the numbers show. */ +@Composable +fun ExercisePickerSheet(state: ExercisePickerState, actions: PickerActions) { ModalBottomSheet( - onDismissRequest = onDismiss, + onDismissRequest = actions.onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { - Column(modifier = Modifier.fillMaxHeight(SHEET_HEIGHT_FRACTION)) { + ExercisePickerContent(state, actions, Modifier.fillMaxHeight(SHEET_HEIGHT_FRACTION)) + } +} + +/** + * The sheet's content: search, chips with Recent first, the list with a number on each pick, and a + * tray of the picks in order above the Add button. + */ +@Composable +internal fun ExercisePickerContent(state: ExercisePickerState, actions: PickerActions, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.picker_title), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp), + ) + PickerSearch(state.query, actions.onQueryChange) + PickerChips(state, actions.onChipSelect) + if (state.options.isEmpty()) { Text( - text = stringResource(R.string.picker_title), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.picker_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(24.dp).weight(1f), ) - OutlinedTextField( - value = state.query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - placeholder = { Text(stringResource(R.string.exercises_search)) }, - leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, - trailingIcon = { - if (state.query.isNotEmpty()) { - IconButton(onClick = { onQueryChange("") }) { - Icon( - Icons.Filled.Clear, - contentDescription = stringResource(R.string.exercises_clear_search) - ) - } - } + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(state.options, key = { it.id }) { exercise -> + val order = state.selectedIds.indexOf(exercise.id) + PickerRow(exercise, order, onToggle = { actions.onToggle(exercise.id) }) + } + } + } + if (state.picks.isNotEmpty()) PicksTray(state.picks, actions.onToggle) + Button( + onClick = actions.onConfirm, + enabled = state.selectedIds.isNotEmpty(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + val count = state.selectedIds.size + Text( + if (count == 0) { + stringResource(R.string.picker_add_none) + } else { + pluralStringResource(R.plurals.picker_add, count, count) }, - singleLine = true, ) - if (state.options.isEmpty()) { - Text( - text = stringResource(R.string.picker_empty), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(24.dp).weight(1f), - ) - } else { - LazyColumn(modifier = Modifier.weight(1f)) { - items(state.options, key = { it.id }) { exercise -> - val checked = exercise.id in state.selectedIds - val muscle = stringResource(exercise.muscleGroup.labelRes) - val equipment = stringResource(exercise.equipment.labelRes) - ListItem( - headlineContent = { Text(exercise.name) }, - supportingContent = { Text(stringResource(R.string.exercise_subtitle, muscle, equipment)) }, - trailingContent = { Checkbox(checked = checked, onCheckedChange = null) }, - colors = ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ), - modifier = Modifier.clickable { onToggle(exercise.id) }, - ) + } + } +} + +@Composable +private fun PickerSearch(query: String, onQueryChange: (String) -> Unit) { + val focusManager = LocalFocusManager.current + Surface( + shape = SearchBarDefaults.inputFieldShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { focusManager.clearFocus() }, + expanded = false, + onExpandedChange = {}, + placeholder = { Text(stringResource(R.string.exercises_search)) }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.exercises_clear_search)) } } - } - Button( - onClick = onConfirm, - enabled = state.selectedIds.isNotEmpty(), - modifier = Modifier.fillMaxWidth().padding(16.dp), - ) { - val count = state.selectedIds.size - Text( - if (count == 0) { - stringResource(R.string.picker_add_none) - } else { - pluralStringResource(R.plurals.picker_add, count, count) - }, - ) - } + }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +/** Recent (when there are recent exercises), All, then a chip per muscle group. */ +@Composable +private fun PickerChips(state: ExercisePickerState, onSelect: (PickerChip) -> Unit) { + val shown = state.shownChip + val chips = + buildList { + if (state.hasRecent) add(PickerChip.Recent) + add(PickerChip.All) + MuscleGroup.entries.forEach { add(PickerChip.Muscle(it)) } + } + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 4.dp), + ) { + items(chips) { chip -> + FilterChip( + selected = chip == shown, + onClick = { onSelect(chip) }, + label = { + Text( + when (chip) { + PickerChip.Recent -> stringResource(R.string.picker_recent) + PickerChip.All -> stringResource(R.string.exercises_filter_all) + is PickerChip.Muscle -> stringResource(chip.group.labelRes) + }, + ) + }, + ) + } + } +} + +/** An exercise to pick. [order] is its place among the picks, from 0, or -1 if it isn't picked. */ +@Composable +private fun PickerRow(exercise: Exercise, order: Int, onToggle: () -> Unit) { + val picked = order >= 0 + val muscle = stringResource(exercise.muscleGroup.labelRes) + val equipment = stringResource(exercise.equipment.labelRes) + ListItem( + headlineContent = { Text(exercise.name) }, + supportingContent = { Text(stringResource(R.string.exercise_subtitle, muscle, equipment)) }, + trailingContent = { PickMark(order) }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + modifier = Modifier.toggleable(value = picked, role = Role.Checkbox, onValueChange = { onToggle() }), + ) +} + +/** A filled disc with the pick's number in tick order, or an empty ring when not picked. */ +@Composable +private fun PickMark(order: Int) { + val colors = MaterialTheme.colorScheme + Box( + contentAlignment = Alignment.Center, + modifier = + if (order >= 0) { + Modifier.size(28.dp).background(colors.primary, CircleShape) + } else { + Modifier.size(28.dp).border(BorderStroke(2.dp, colors.outline), CircleShape) + }, + ) { + if (order >= 0) { + Text( + text = (order + 1).toString(), + style = MaterialTheme.typography.labelLarge.tabular(), + color = colors.onPrimary, + ) + } + } +} + +/** The picks in the order they'll be added, each removable. */ +@Composable +private fun PicksTray(picks: List, onRemove: (String) -> Unit) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + itemsIndexed(picks, key = { _, exercise -> exercise.id }) { index, exercise -> + val remove = stringResource(R.string.picker_remove, exercise.name) + InputChip( + selected = true, + onClick = { onRemove(exercise.id) }, + label = { Text(stringResource(R.string.picker_tray_item, index + 1, exercise.name)) }, + trailingIcon = { + Icon( + Icons.Filled.Close, + contentDescription = remove, + modifier = Modifier.size(InputChipDefaults.IconSize) + ) + }, + ) } } } diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/FinishSummary.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/FinishSummary.kt index f2d5c78..4e2b71a 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/FinishSummary.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/FinishSummary.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -44,8 +43,8 @@ import io.github.prithvimk.gymtracker.domain.model.PrType import io.github.prithvimk.gymtracker.ui.components.StatTile import io.github.prithvimk.gymtracker.ui.format.durationText import io.github.prithvimk.gymtracker.ui.format.percentChangeText +import io.github.prithvimk.gymtracker.ui.format.topSetText import io.github.prithvimk.gymtracker.ui.format.volumeText -import io.github.prithvimk.gymtracker.ui.format.weightText import io.github.prithvimk.gymtracker.ui.history.formatDate import io.github.prithvimk.gymtracker.ui.theme.GymTrackerTheme import io.github.prithvimk.gymtracker.ui.theme.tabular @@ -247,14 +246,6 @@ private fun ComparisonRow(label: String, value: String, was: String, change: Top } } -/** "82.5 kg × 8", or "12 reps" for a bodyweight set. */ -@Composable -private fun topSetText(top: TopSet): String = if (top.weightKg > 0) { - stringResource(R.string.exercise_detail_set, weightText(top.weightKg), top.reps) -} else { - pluralStringResource(R.plurals.exercise_detail_set_reps, top.reps, top.reps) -} - @Preview(showBackground = true) @Composable private fun FinishSummaryPreview() { diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/SessionEditor.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/SessionEditor.kt index 97c6fc3..8deb880 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/SessionEditor.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/SessionEditor.kt @@ -4,8 +4,6 @@ import io.github.prithvimk.gymtracker.data.db.entity.WorkoutSet import io.github.prithvimk.gymtracker.data.repository.ExerciseRepository import io.github.prithvimk.gymtracker.data.repository.WorkoutRepository import io.github.prithvimk.gymtracker.domain.model.SetType -import io.github.prithvimk.gymtracker.ui.exercises.ExerciseFilter -import io.github.prithvimk.gymtracker.ui.exercises.filteredBy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -38,8 +36,12 @@ class SessionEditor( private val pickerInput = MutableStateFlow(null) val picker: StateFlow = - combine(pickerInput, exerciseRepository.observeExercises()) { input, all -> - input?.copy(options = all.filteredBy(ExerciseFilter(query = input.query))) + combine( + pickerInput, + exerciseRepository.observeExercises(), + workoutRepository.observeRecentExerciseIds(), + ) { input, all, recent -> + input?.withLibrary(all, recent) }.stateIn(scope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) fun onAddExercisesClick() { @@ -50,6 +52,10 @@ class SessionEditor( pickerInput.update { it?.copy(query = query) } } + fun onPickerChipSelect(chip: PickerChip) { + pickerInput.update { it?.copy(chip = chip) } + } + fun onPickerToggle(exerciseId: String) { pickerInput.update { it?.copy(selectedIds = it.selectedIds.toggled(exerciseId)) } } diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/WorkoutScreen.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/WorkoutScreen.kt index 0eb632e..e5e604f 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/WorkoutScreen.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/WorkoutScreen.kt @@ -265,10 +265,14 @@ internal fun SessionEditorDialogs(editor: SessionEditor) { picker?.let { pickerState -> ExercisePickerSheet( state = pickerState, - onQueryChange = editor::onPickerQueryChange, - onToggle = editor::onPickerToggle, - onConfirm = editor::onPickerConfirm, - onDismiss = editor::onPickerDismiss, + actions = + PickerActions( + onQueryChange = editor::onPickerQueryChange, + onChipSelect = editor::onPickerChipSelect, + onToggle = editor::onPickerToggle, + onConfirm = editor::onPickerConfirm, + onDismiss = editor::onPickerDismiss, + ), ) } diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/WorkoutViewModel.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/WorkoutViewModel.kt index c994be3..747fd5d 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/WorkoutViewModel.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/WorkoutViewModel.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import io.github.prithvimk.gymtracker.GymTrackerApp import io.github.prithvimk.gymtracker.data.db.dao.WorkoutExerciseEntry -import io.github.prithvimk.gymtracker.data.db.entity.Exercise import io.github.prithvimk.gymtracker.data.db.entity.Workout import io.github.prithvimk.gymtracker.data.db.entity.WorkoutSet import io.github.prithvimk.gymtracker.data.prefs.Hint @@ -90,13 +89,6 @@ data class RestEdit(val workoutExerciseId: String, val exerciseName: String, val /** An exercise waiting on confirmation before it and its [setCount] sets are removed. */ data class PendingRemoval(val workoutExerciseId: String, val name: String, val setCount: Int) -/** The add-exercises sheet. [selectedIds] keeps tap order, which is the order the exercises are added in. */ -data class ExercisePickerState( - val query: String = "", - val selectedIds: List = emptyList(), - val options: List = emptyList(), -) - @OptIn(ExperimentalCoroutinesApi::class) class WorkoutViewModel( private val workoutRepository: WorkoutRepository, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5743d3b..cca5e62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,7 +99,6 @@ Workout - %1$s · %2$s · %3$s %d set %d sets @@ -108,8 +107,13 @@ %d PR %d PRs - List - Calendar + Show list + Show calendar + + Less + More volume + + %1$s +%2$d Previous month Next month No workouts this month. @@ -127,10 +131,14 @@ Done Changes save as you make them, and records are worked out again. Notes - No sets done + + Skipped + More options + + Open exercise history - %d set not done - %d sets not done + %d planned set skipped + %d planned sets skipped Heaviest weight: %1$s @@ -249,6 +257,10 @@ Add exercises + Recent + + %1$d %2$s + Remove %1$s No exercises match. Try another search. Select exercises to add @@ -259,11 +271,19 @@ Exercises Search exercises + + Search %d exercise + Search %d exercises + + Equipment + Any equipment + + Last %1$s · %2$s Clear search All Show archived New exercise - No exercises match. Try another search or muscle group. + No exercises match. Try another search or filter. Custom Archived %1$s · %2$s @@ -272,6 +292,24 @@ Back Edit exercise Personal records + Estimated 1RM + + + %1$s in %2$d day + %1$s in %2$d days + + + No change in %1$d day + No change in %1$d days + + + %1$s · %2$s + Heaviest + Most reps + Best e1RM + Session volume + + %1$d \@ %2$s No records yet. Complete a working set to set the first ones. History Not logged yet. Complete a set of this exercise in a workout and it shows up here. diff --git a/app/src/test/java/io/github/prithvimk/gymtracker/data/repository/WorkoutRepositoryTest.kt b/app/src/test/java/io/github/prithvimk/gymtracker/data/repository/WorkoutRepositoryTest.kt index f7e5712..b94039e 100644 --- a/app/src/test/java/io/github/prithvimk/gymtracker/data/repository/WorkoutRepositoryTest.kt +++ b/app/src/test/java/io/github/prithvimk/gymtracker/data/repository/WorkoutRepositoryTest.kt @@ -13,6 +13,8 @@ import io.github.prithvimk.gymtracker.data.routineExercise import io.github.prithvimk.gymtracker.data.workout import io.github.prithvimk.gymtracker.data.workoutExercise import io.github.prithvimk.gymtracker.data.workoutSet +import io.github.prithvimk.gymtracker.domain.LastTopSet +import io.github.prithvimk.gymtracker.domain.TopSet import io.github.prithvimk.gymtracker.domain.WorkoutSummary import io.github.prithvimk.gymtracker.domain.model.PrType import io.github.prithvimk.gymtracker.domain.model.SetType @@ -883,6 +885,62 @@ class WorkoutRepositoryTest { assertNull(repository.compareWithPrevious("nothing-done")) } + @Test + fun `last top sets come from each exercise's last finished workout, leaving out warm-ups`() = runTest { + val dao = db.workoutDao() + logSession("older", startedAt = 100L, routineId = null, benchKg = 90.0, ohpKg = 50.0) + logSession("newer", startedAt = 200L, routineId = null, benchKg = 80.0) + dao.upsertSet(workoutSet("newer-b2", "newer-bench", 1, weightKg = 80.0, reps = 9, completedAt = 202L)) + dao.upsertSet( + workoutSet( + "newer-w", + "newer-bench", + 2, + weightKg = 120.0, + reps = 1, + type = SetType.WARMUP, + completedAt = 203L + ), + ) + // Neither a running workout nor a set not ticked counts. + dao.upsert(workout("running", startedAt = 300L)) + dao.upsertExercise(workoutExercise("running-bench", "running", "bench")) + dao.upsertSet(workoutSet("running-1", "running-bench", 0, weightKg = 200.0, reps = 1, completedAt = 301L)) + dao.upsertSet(workoutSet("newer-undone", "newer-bench", 3, weightKg = 150.0, reps = 1)) + + val tops = repository.observeLastTopSets().first() + + assertEquals(LastTopSet(TopSet(80.0, 9), 200L), tops["bench"]) + assertEquals(LastTopSet(TopSet(50.0, 8), 100L), tops["ohp"]) + assertNull(tops["fly"]) + } + + @Test + fun `recent exercises come from the last finished workouts, newest first, each once`() = runTest { + logSession("old", startedAt = 100L, routineId = null, benchKg = 60.0, ohpKg = 40.0) + val dao = db.workoutDao() + dao.upsert(workout("new", startedAt = 200L, endedAt = 210L)) + dao.upsertExercise(workoutExercise("new-fly", "new", "fly", position = 0)) + dao.upsertExercise(workoutExercise("new-bench", "new", "bench", position = 1)) + // The running workout's exercises aren't recent yet. + dao.upsert(workout("running", startedAt = 300L)) + dao.upsertExercise(workoutExercise("running-ohp", "running", "ohp")) + + assertEquals(listOf("fly", "bench", "ohp"), repository.observeRecentExerciseIds().first()) + } + + @Test + fun `history exercise names are each finished workout's, in order`() = runTest { + logSession("done", startedAt = 100L, routineId = null, benchKg = 60.0, ohpKg = 40.0) + db.workoutDao().upsert(workout("running", startedAt = 300L)) + db.workoutDao().upsertExercise(workoutExercise("running-fly", "running", "fly")) + + assertEquals( + mapOf("done" to listOf("Bench Press", "Overhead Press")), + repository.observeHistoryExerciseNames().first(), + ) + } + /** A finished workout with one completed set of 8 on bench and, if given, overhead press. */ private suspend fun logSession( id: String, diff --git a/app/src/test/java/io/github/prithvimk/gymtracker/ui/exercises/E1rmTrendTest.kt b/app/src/test/java/io/github/prithvimk/gymtracker/ui/exercises/E1rmTrendTest.kt new file mode 100644 index 0000000..212789b --- /dev/null +++ b/app/src/test/java/io/github/prithvimk/gymtracker/ui/exercises/E1rmTrendTest.kt @@ -0,0 +1,72 @@ +package io.github.prithvimk.gymtracker.ui.exercises + +import io.github.prithvimk.gymtracker.data.db.dao.ExerciseHistoryRow +import io.github.prithvimk.gymtracker.data.workoutSet +import io.github.prithvimk.gymtracker.domain.model.SetType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class E1rmTrendTest { + @Test + fun `each session's best estimated 1RM, oldest first, from working sets of up to 12 reps`() { + val rows = + listOf( + row("new", startedAt = 20L, weightKg = 100.0, reps = 3), + row("new", startedAt = 20L, weightKg = 90.0, reps = 6), + row("new", startedAt = 20L, weightKg = 140.0, reps = 1, type = SetType.WARMUP), + row("old", startedAt = 10L, weightKg = 80.0, reps = 8), + // Only warm-ups and long sets: no estimate for this session. + row("light", startedAt = 15L, weightKg = 40.0, reps = 20), + ) + + val points = e1rmPoints(rows) + + assertEquals(listOf(10L, 20L), points.map { it.startedAt }) + assertEquals(80.0 * (1 + 8 / 30.0), points[0].kg, 1e-9) + assertEquals(100.0 * (1 + 3 / 30.0), points[1].kg, 1e-9) + } + + @Test + fun `the change is against the last session at least 30 days old`() { + val now = 100 * DAY + val points = + listOf( + E1rmPoint(now - 60 * DAY, 90.0), + E1rmPoint(now - 31 * DAY, 95.0), + E1rmPoint(now - 10 * DAY, 98.0), + E1rmPoint(now - DAY, 100.0), + ) + + assertEquals(5.0, e1rmChange(points, now)!!, 1e-9) + } + + @Test + fun `no change without a session that old, or when the latest is the old one`() { + val now = 100 * DAY + + assertNull(e1rmChange(listOf(E1rmPoint(now - 20 * DAY, 90.0), E1rmPoint(now, 95.0)), now)) + assertNull(e1rmChange(listOf(E1rmPoint(now - 40 * DAY, 90.0)), now)) + assertNull(e1rmChange(emptyList(), now)) + } + + private fun row(workoutId: String, startedAt: Long, weightKg: Double, reps: Int, type: SetType = SetType.WORKING,) = + ExerciseHistoryRow( + workoutId = workoutId, + workoutStartedAt = startedAt, + workoutEndedAt = startedAt + 1L, + routineName = null, + set = workoutSet( + "$workoutId-$weightKg-$reps", + "we-$workoutId", + weightKg = weightKg, + reps = reps, + type = type, + completedAt = 1L + ), + ) + + private companion object { + const val DAY = 86_400_000L + } +} diff --git a/app/src/test/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseFilterTest.kt b/app/src/test/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseFilterTest.kt index 98e8b70..d6376ea 100644 --- a/app/src/test/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseFilterTest.kt +++ b/app/src/test/java/io/github/prithvimk/gymtracker/ui/exercises/ExerciseFilterTest.kt @@ -1,6 +1,7 @@ package io.github.prithvimk.gymtracker.ui.exercises import io.github.prithvimk.gymtracker.data.exercise +import io.github.prithvimk.gymtracker.domain.model.Equipment import io.github.prithvimk.gymtracker.domain.model.MuscleGroup import org.junit.Assert.assertEquals import org.junit.Test @@ -37,4 +38,32 @@ class ExerciseFilterTest { assertEquals(listOf("1", "3"), ids(criteria)) } + + @Test + fun `equipment narrows the list, alongside the muscle group`() { + val mixed = + listOf( + exercise( + id = "a", + name = "Bench Press", + muscleGroup = MuscleGroup.CHEST, + equipment = Equipment.BARBELL + ), + exercise( + id = "b", + name = "Dumbbell Press", + muscleGroup = MuscleGroup.CHEST, + equipment = Equipment.DUMBBELL + ), + exercise(id = "c", name = "Row", muscleGroup = MuscleGroup.BACK, equipment = Equipment.BARBELL), + ) + + assertEquals(listOf("a", "c"), mixed.filteredBy(ExerciseFilter(equipment = Equipment.BARBELL)).map { it.id }) + assertEquals( + listOf("a"), + mixed.filteredBy(ExerciseFilter(muscleGroup = MuscleGroup.CHEST, equipment = Equipment.BARBELL)).map { + it.id + }, + ) + } } diff --git a/app/src/test/java/io/github/prithvimk/gymtracker/ui/history/HistoryCalendarTest.kt b/app/src/test/java/io/github/prithvimk/gymtracker/ui/history/HistoryCalendarTest.kt index 17a1b7c..ee09eba 100644 --- a/app/src/test/java/io/github/prithvimk/gymtracker/ui/history/HistoryCalendarTest.kt +++ b/app/src/test/java/io/github/prithvimk/gymtracker/ui/history/HistoryCalendarTest.kt @@ -99,4 +99,31 @@ class HistoryCalendarTest { val startedAt = start.atZone(zone).toInstant().toEpochMilli() return WorkoutHistoryRow(id, null, startedAt, startedAt + 3_600_000L, 10, 1_000.0, 0) } + + @Test + fun `trained days are tinted by which third of the busiest day's volume they reach`() { + val day = { d: Int -> LocalDate.of(2026, 10, d) } + val levels = + volumeLevels( + mapOf( + day(1) to listOf(volumeRow("a", 9_000.0)), + // Two workouts on one day add up: 6,000 is two thirds of 9,000. + day(2) to listOf(volumeRow("b", 4_000.0), volumeRow("c", 2_000.0)), + day(3) to listOf(volumeRow("d", 2_000.0)), + day(4) to listOf(volumeRow("e", 0.0)), + ), + ) + + assertEquals(mapOf(day(1) to 3, day(2) to 2, day(3) to 1, day(4) to 1), levels) + } + + @Test + fun `a month of bodyweight work is all the first step`() { + val day = LocalDate.of(2026, 10, 5) + + assertEquals(mapOf(day to 1), volumeLevels(mapOf(day to listOf(volumeRow("a", 0.0))))) + assertEquals(emptyMap(), volumeLevels(emptyMap())) + } + + private fun volumeRow(id: String, volumeKg: Double) = WorkoutHistoryRow(id, null, 0L, 1L, 10, volumeKg, 0) } diff --git a/app/src/test/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePickerTest.kt b/app/src/test/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePickerTest.kt new file mode 100644 index 0000000..17211c4 --- /dev/null +++ b/app/src/test/java/io/github/prithvimk/gymtracker/ui/workout/ExercisePickerTest.kt @@ -0,0 +1,60 @@ +package io.github.prithvimk.gymtracker.ui.workout + +import io.github.prithvimk.gymtracker.data.exercise +import io.github.prithvimk.gymtracker.domain.model.MuscleGroup +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExercisePickerTest { + private val library = + listOf( + exercise(id = "bench", name = "Bench Press", muscleGroup = MuscleGroup.CHEST), + exercise(id = "fly", name = "Cable Fly", muscleGroup = MuscleGroup.CHEST), + exercise(id = "ohp", name = "Overhead Press", muscleGroup = MuscleGroup.SHOULDERS), + exercise(id = "old", name = "Old Press", muscleGroup = MuscleGroup.SHOULDERS, archived = true), + exercise(id = "row", name = "Barbell Row", muscleGroup = MuscleGroup.BACK), + ) + + private fun ids(state: ExercisePickerState) = state.options.map { it.id } + + @Test + fun `Recent is on by default and lists recent exercises, most recent first, without archived ones`() { + val state = ExercisePickerState().withLibrary(library, listOf("ohp", "old", "bench", "ohp")) + + assertTrue(state.hasRecent) + assertEquals(PickerChip.Recent, state.shownChip) + assertEquals(listOf("ohp", "bench"), ids(state)) + } + + @Test + fun `with nothing recent, All is on`() { + val state = ExercisePickerState().withLibrary(library, emptyList()) + + assertFalse(state.hasRecent) + assertEquals(PickerChip.All, state.shownChip) + assertEquals(listOf("bench", "fly", "ohp", "row"), ids(state)) + } + + @Test + fun `a search under Recent looks through the whole library, under a muscle only in it`() { + val recent = listOf("bench") + + assertEquals(listOf("bench", "ohp"), ids(ExercisePickerState(query = "press").withLibrary(library, recent))) + assertEquals( + listOf("ohp"), + ids( + ExercisePickerState(query = "press", chip = PickerChip.Muscle(MuscleGroup.SHOULDERS)) + .withLibrary(library, recent), + ), + ) + } + + @Test + fun `picks come out in tick order for the tray`() { + val state = ExercisePickerState(selectedIds = listOf("row", "bench")).withLibrary(library, emptyList()) + + assertEquals(listOf("row", "bench"), state.picks.map { it.id }) + } +} diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 4db13dd..1f3bbd1 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -132,7 +132,8 @@ Settled while planning, to keep the picks from contradicting each other: - **D2 is part done** (#23): the aligned set table, gold PR disc, keypad context line and RPE opt-in (schema v3: `exercises.tracksRpe`, which Phase 5's JSON export must include). The workout detail's editor already shares these. Then the compact top bar and Resume workout on the finish summary (#25). - **D2 is done**: the rest chip and peek replaced the rest bar. On a Pixel 7 with the bottom bar, the keypad open and a rest running, all four sets of a four-set exercise show under its header. - **D3 is done**: the Up next card, routine rows with last done and typical duration, and the full-screen finish summary with its comparison against the routine's last session. -- **Next up:** D4 and D5, in any order. +- **D4 is done**: History cards and the volume-tinted calendar, the workout detail's tiles and links, the Exercises list with last top sets, the e1RM hero and record tiles, and the picker's chips, numbers and tray. +- **Next up:** D5. ## Phase D1: Design foundations @@ -177,12 +178,12 @@ Done when: finishing a routine shows its volume change against the last time you Goal: past work and progress read at a glance. -- [ ] History cards: date line with a gold PR chip, routine name, one row of stats, and the first few exercises; the List / Calendar switch becomes a top-bar icon (F10) -- [ ] Calendar: trained days tinted in three steps by volume (thirds of the month's highest), selected day filled, today always ringed (F11) -- [ ] Workout detail: stat tiles, Delete moves to the menu, undone sets collapse to one line, exercise names open their detail (F12) -- [ ] Exercises: Material 3 search bar, muscle chips plus an Equipment chip, and each trained exercise's last top set and when; no monogram avatars (F13) -- [ ] Exercise detail: e1RM hero with its 30-day change and a sparkline, records as a 2×2 grid of tiles with dates, then history (F14) -- [ ] Exercise picker: chips with Recent first, numbered picks in tick order, and a tray of picks above the Add button (F16) +- [x] History cards: date line with a gold PR chip, routine name, one row of stats, and the first few exercises; the List / Calendar switch becomes a top-bar icon (F10) (the first three exercises, then "+N"; the icon sits beside the History title and shows the view it switches to) +- [x] Calendar: trained days tinted in three steps by volume (thirds of the month's highest), selected day filled, today always ringed (F11) (by each day's total volume; a day that moved no weight is the first step; the steps run from half-way to the primary container up to half-way on to primary, which is kept for the picked day; a "Less … More volume" key sits under the grid) +- [x] Workout detail: stat tiles, Delete moves to the menu, undone sets collapse to one line, exercise names open their detail (F12) (an exercise with nothing done is a one-line "Skipped" card; others end with "N planned sets skipped"; the exercise detail opens under History, per the design decision) +- [x] Exercises: Material 3 search bar, muscle chips plus an Equipment chip, and each trained exercise's last top set and when; no monogram avatars (F13) (the search bar reads "Search 69 exercises"; Equipment comes first in the chip row so it is on screen, and opens a menu; the last top set is the heaviest non-warm-up set of the last finished workout with the exercise, then the most reps) +- [x] Exercise detail: e1RM hero with its 30-day change and a sparkline, records as a 2×2 grid of tiles with dates, then history (F14) (the hero shows the latest session's best e1RM; the change is against the last session at least 30 days old and is hidden until there is one; the sparkline has every session's best e1RM) +- [x] Exercise picker: chips with Recent first, numbered picks in tick order, and a tray of picks above the Add button (F16) (Recent is the exercises of the last 5 finished workouts and is on by default when there are any; a search under Recent looks through the whole library, under a muscle chip only within it; tapping a pick in the tray removes it; the routine editor's picker gets the same) Done when: the Exercises list shows your last top set for every exercise you've trained.