Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package io.github.prithvimk.gymtracker.ui.components

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.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.github.prithvimk.gymtracker.R
import io.github.prithvimk.gymtracker.ui.theme.GymTrackerTheme
import io.github.prithvimk.gymtracker.ui.theme.recordColors
import io.github.prithvimk.gymtracker.ui.theme.tabular

/** A gold pill with a trophy and [text], e.g. "3 PRs", marking something that holds records. */
@Composable
fun RecordChip(text: String, modifier: Modifier = Modifier) {
val colors = MaterialTheme.recordColors
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
.background(colors.recordContainer, CircleShape)
.padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp),
) {
Icon(
painter = painterResource(R.drawable.ic_trophy),
contentDescription = null,
tint = colors.onRecordContainer,
modifier = Modifier.size(16.dp),
)
Text(
text = text,
style = MaterialTheme.typography.labelLarge.tabular(),
color = colors.onRecordContainer,
)
}
}

/** [text], usually a set number, on a gold disc: the mark of a set that holds a record. */
@Composable
fun RecordDisc(text: String, modifier: Modifier = Modifier, size: Dp = 28.dp) {
val colors = MaterialTheme.recordColors
Box(
contentAlignment = Alignment.Center,
modifier = modifier.size(size).background(colors.record, CircleShape),
) {
Text(text = text, style = MaterialTheme.typography.labelLarge.tabular(), color = colors.onRecord)
}
}

/** A number with its label under it, e.g. "58 min" over "Duration", on a tinted tile. */
@Composable
fun StatTile(value: String, label: String, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge.tabular(),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}

@Preview(showBackground = true)
@Composable
private fun RecordMarksPreview() {
GymTrackerTheme {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp)) {
RecordChip("3 PRs")
RecordDisc("1")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatTile("58 min", "Duration", Modifier.weight(1f))
StatTile("8,420 kg", "Volume", Modifier.weight(1f))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.github.prithvimk.gymtracker.R
import io.github.prithvimk.gymtracker.ui.workout.formatElapsed
import io.github.prithvimk.gymtracker.ui.format.formatClock

/** A label with its value between − and + buttons. */
@Composable
Expand Down Expand Up @@ -56,7 +56,7 @@ fun Stepper(
/** A rest time as "2:00", or "Off" for none. */
@Composable
fun restText(seconds: Int): String =
if (seconds <= 0) stringResource(R.string.rest_off) else formatElapsed(seconds * MILLIS_PER_SECOND)
if (seconds <= 0) stringResource(R.string.rest_off) else formatClock(seconds * MILLIS_PER_SECOND)

private val VALUE_MIN_WIDTH = 56.dp
private const val MILLIS_PER_SECOND = 1_000L
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ 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.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.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.formatVolume
import io.github.prithvimk.gymtracker.ui.workout.recordText
import io.github.prithvimk.gymtracker.ui.workout.text
import java.time.Instant
Expand Down Expand Up @@ -189,7 +191,7 @@ private fun RecordsCard(records: List<RecordBest>) {
Icon(
painter = painterResource(R.drawable.ic_trophy),
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
tint = MaterialTheme.recordColors.record,
modifier = Modifier.size(18.dp),
)
Text(
Expand Down Expand Up @@ -226,12 +228,12 @@ private fun SessionCard(session: HistorySession) {
session.sets.forEach { HistorySetRow(it) }
}
Row(verticalAlignment = Alignment.CenterVertically) {
MutedText(stringResource(R.string.exercise_detail_volume, formatVolume(session.volumeKg)))
MutedText(stringResource(R.string.exercise_detail_volume, volumeText(session.volumeKg)))
if (session.volumeRecord) {
Icon(
painter = painterResource(R.drawable.ic_trophy),
contentDescription = stringResource(R.string.exercise_detail_volume_record),
tint = MaterialTheme.colorScheme.tertiary,
tint = MaterialTheme.recordColors.record,
modifier = Modifier.padding(start = 6.dp).size(16.dp),
)
}
Expand All @@ -254,7 +256,7 @@ internal fun HistorySetRow(historySet: HistorySet, modifier: Modifier = Modifier
Text(
text =
if (set.weightKg > 0) {
stringResource(R.string.exercise_detail_set, formatSetNumber(set.weightKg), set.reps)
stringResource(R.string.exercise_detail_set, weightText(set.weightKg), set.reps)
} else {
pluralStringResource(R.plurals.exercise_detail_set_reps, set.reps, set.reps)
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package io.github.prithvimk.gymtracker.ui.format

import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import io.github.prithvimk.gymtracker.R
import java.text.NumberFormat
import java.util.Locale

// The app's one formatter for weights and durations. Every weight shown outside the set fields goes
// through here, so a display unit other than kg is a change to this file alone.

/**
* A live timer as "m:ss", or "h:mm:ss" from one hour. Negative input counts as zero. Only running
* clocks (the workout's elapsed time, the rest countdown) and rest settings use this; a finished
* duration reads in words, see [durationText].
*/
fun formatClock(millis: Long): String {
val totalSeconds = millis.coerceAtLeast(0L) / MILLIS_PER_SECOND
val hours = totalSeconds / SECONDS_PER_HOUR
val minutes = totalSeconds % SECONDS_PER_HOUR / SECONDS_PER_MINUTE
val seconds = totalSeconds % SECONDS_PER_MINUTE
return if (hours > 0) {
String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format(Locale.ROOT, "%d:%02d", minutes, seconds)
}
}

/** A finished duration in whole hours and minutes, rounded to the nearest minute. */
data class DurationParts(val hours: Long, val minutes: Long)

fun durationParts(millis: Long): DurationParts {
val totalMinutes = (millis.coerceAtLeast(0L) + MILLIS_PER_MINUTE / 2) / MILLIS_PER_MINUTE
return DurationParts(totalMinutes / MINUTES_PER_HOUR, totalMinutes % MINUTES_PER_HOUR)
}

/** A finished duration in words: "58 min", "1 h 11 min", or "2 h" on the hour. */
@Composable
@ReadOnlyComposable
fun durationText(millis: Long): String {
val (hours, minutes) = durationParts(millis)
return when {
hours == 0L -> stringResource(R.string.duration_minutes, minutes)
minutes == 0L -> stringResource(R.string.duration_hours, hours)
else -> stringResource(R.string.duration_hours_minutes, hours, minutes)
}
}

/** A weight's number in the display unit, grouped, with at most two decimals: "82.5", "1,000", "1.25". */
fun formatWeight(kg: Double, locale: Locale = Locale.getDefault()): String = numberFormat(locale, 2).format(kg)

/** A volume's or estimate's number in the display unit, grouped, with at most one decimal: "12,345.5". */
fun formatVolume(kg: Double, locale: Locale = Locale.getDefault()): String = numberFormat(locale, 1).format(kg)

/** A weight with its unit, e.g. "82.5 kg". */
@Composable
@ReadOnlyComposable
fun weightText(kg: Double): String = stringResource(R.string.weight_value, formatWeight(kg, currentLocale()))

/** A volume or estimated 1RM with its unit, e.g. "8,420 kg". */
@Composable
@ReadOnlyComposable
fun volumeText(kg: Double): String = stringResource(R.string.weight_value, formatVolume(kg, currentLocale()))

/** A weight's number alone, for compact columns where the unit is in the header, e.g. "80" of "80 × 8". */
@Composable
@ReadOnlyComposable
fun weightNumberText(kg: Double): String = formatWeight(kg, currentLocale())

@Composable
@ReadOnlyComposable
private fun currentLocale(): Locale = LocalConfiguration.current.locales[0]

private fun numberFormat(locale: Locale, maxDecimals: Int): NumberFormat =
NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = maxDecimals }

private const val MILLIS_PER_SECOND = 1_000L
private const val MILLIS_PER_MINUTE = 60_000L
private const val SECONDS_PER_MINUTE = 60L
private const val SECONDS_PER_HOUR = 3_600L
private const val MINUTES_PER_HOUR = 60L
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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
Expand All @@ -51,9 +50,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import io.github.prithvimk.gymtracker.R
import io.github.prithvimk.gymtracker.data.db.dao.WorkoutHistoryRow
import io.github.prithvimk.gymtracker.ui.components.RecordChip
import io.github.prithvimk.gymtracker.ui.format.durationText
import io.github.prithvimk.gymtracker.ui.format.volumeText
import io.github.prithvimk.gymtracker.ui.theme.GymTrackerTheme
import io.github.prithvimk.gymtracker.ui.workout.formatElapsed
import io.github.prithvimk.gymtracker.ui.workout.formatVolume
import io.github.prithvimk.gymtracker.ui.theme.tabular
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
Expand Down Expand Up @@ -250,7 +251,7 @@ private fun DayCell(day: LocalDate, count: Int, isToday: Boolean, selected: Bool
modifier = Modifier
.padding(3.dp)
.fillMaxSize()
.then(if (selected) Modifier.border(2.dp, colors.tertiary, CircleShape) else Modifier)
.then(if (selected) Modifier.border(2.dp, colors.onSurface, CircleShape) else Modifier)
.padding(if (selected) 3.dp else 0.dp)
.clip(CircleShape)
.then(if (trained) Modifier.background(colors.primary) else Modifier)
Expand Down Expand Up @@ -292,9 +293,9 @@ private fun MonthListHeader(calendar: HistoryMonth) {
stringResource(
R.string.history_week_totals,
pluralStringResource(R.plurals.history_week_workouts, listed.size, listed.size),
formatVolume(listed.sumOf { it.volumeKg }),
volumeText(listed.sumOf { it.volumeKg }),
),
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.tabular(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Expand All @@ -321,9 +322,9 @@ private fun WeekHeader(week: HistoryWeek) {
stringResource(
R.string.history_week_totals,
pluralStringResource(R.plurals.history_week_workouts, count, count),
formatVolume(week.volumeKg),
volumeText(week.volumeKg),
),
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.tabular(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Expand All @@ -346,31 +347,21 @@ internal fun WorkoutCard(workout: WorkoutHistoryRow, onClick: () -> Unit, modifi
text =
stringResource(
R.string.history_workout_stats,
formatElapsed(workout.endedAt - workout.startedAt),
durationText(workout.endedAt - workout.startedAt),
pluralStringResource(R.plurals.history_workout_sets, workout.setCount, workout.setCount),
stringResource(R.string.workout_summary_volume_value, formatVolume(workout.volumeKg)),
volumeText(workout.volumeKg),
),
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.tabular(),
)
if (workout.recordCount > 0) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
Icon(
painter = painterResource(R.drawable.ic_trophy),
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(18.dp),
)
Text(
text = pluralStringResource(
R.plurals.history_workout_records,
workout.recordCount,
workout.recordCount
),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(start = 6.dp),
)
}
RecordChip(
text = pluralStringResource(
R.plurals.history_workout_records,
workout.recordCount,
workout.recordCount
),
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ import io.github.prithvimk.gymtracker.R
import io.github.prithvimk.gymtracker.domain.WorkoutSummary
import io.github.prithvimk.gymtracker.ui.exercises.HistorySetRow
import io.github.prithvimk.gymtracker.ui.exercises.labelRes
import io.github.prithvimk.gymtracker.ui.format.durationText
import io.github.prithvimk.gymtracker.ui.format.volumeText
import io.github.prithvimk.gymtracker.ui.workout.KeypadState
import io.github.prithvimk.gymtracker.ui.workout.RecordsCelebration
import io.github.prithvimk.gymtracker.ui.workout.SessionEditActions
import io.github.prithvimk.gymtracker.ui.workout.SessionEditorContent
import io.github.prithvimk.gymtracker.ui.workout.SessionEditorDialogs
import io.github.prithvimk.gymtracker.ui.workout.formatElapsed
import io.github.prithvimk.gymtracker.ui.workout.formatVolume

/** A finished workout, opened from History. */
@Composable
Expand Down Expand Up @@ -245,13 +245,13 @@ private fun DoneExerciseCard(exercise: DoneExercise, notDone: Int) {
}
}

/** "1:02:15 · 18 sets · 8,420 kg", the same as the workout's card in History. */
/** "1 h 2 min · 18 sets · 8,420 kg", the same as the workout's card in History. */
@Composable
private fun statsText(summary: WorkoutSummary): String = stringResource(
R.string.history_workout_stats,
formatElapsed(summary.durationMillis),
durationText(summary.durationMillis),
pluralStringResource(R.plurals.history_workout_sets, summary.setCount, summary.setCount),
stringResource(R.string.workout_summary_volume_value, formatVolume(summary.volumeKg)),
volumeText(summary.volumeKg),
)

@Composable
Expand Down
Loading
Loading