diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/components/RecordMarks.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/components/RecordMarks.kt new file mode 100644 index 0000000..cf3eacb --- /dev/null +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/components/RecordMarks.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/components/Stepper.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/components/Stepper.kt index f333852..27700e6 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/components/Stepper.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/components/Stepper.kt @@ -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 @@ -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 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 2f08b82..40866e5 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 @@ -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 @@ -189,7 +191,7 @@ private fun RecordsCard(records: List) { Icon( painter = painterResource(R.drawable.ic_trophy), contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary, + tint = MaterialTheme.recordColors.record, modifier = Modifier.size(18.dp), ) Text( @@ -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), ) } @@ -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) }, 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 new file mode 100644 index 0000000..9adfb29 --- /dev/null +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/format/Formats.kt @@ -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 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 5e68270..4122c7e 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 @@ -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 @@ -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 @@ -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) @@ -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, ) } @@ -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, ) } @@ -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), + ) } } } 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 e5d4256..5f0409c 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 @@ -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 @@ -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 diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/theme/Theme.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/theme/Theme.kt index 4e4c160..26e0176 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/theme/Theme.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/theme/Theme.kt @@ -8,35 +8,143 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +// Generated from the brand green #2E6B30 with Material's tonal palettes (the Theme Builder's mapping: +// primary chroma 48, secondary 16, tertiary hue +60 at 24, neutrals 4 and 8), primary pinned to the +// brand colour itself. Every role is set, surface containers included, so nothing falls back to the +// library's purple baseline. private val appLightColors = lightColorScheme( primary = Color(0xFF2E6B30), onPrimary = Color(0xFFFFFFFF), - primaryContainer = Color(0xFFB0F2A8), + primaryContainer = Color(0xFFAAF5A4), onPrimaryContainer = Color(0xFF002204), + inversePrimary = Color(0xFF8FD88A), secondary = Color(0xFF52634F), - background = Color(0xFFFBFEFB), - surface = Color(0xFFFBFEFB), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFD5E8CF), + onSecondaryContainer = Color(0xFF101F0F), + tertiary = Color(0xFF38656A), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFBCEBF1), + onTertiaryContainer = Color(0xFF002023), + background = Color(0xFFF9FAF4), + onBackground = Color(0xFF1A1C19), + surface = Color(0xFFF9FAF4), + onSurface = Color(0xFF1A1C19), + surfaceVariant = Color(0xFFDEE5D8), + onSurfaceVariant = Color(0xFF424940), + surfaceTint = Color(0xFF2E6B30), + inverseSurface = Color(0xFF2F312D), + inverseOnSurface = Color(0xFFF0F1EB), + error = Color(0xFFBA1A1A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + outline = Color(0xFF72796F), + outlineVariant = Color(0xFFC2C9BD), + scrim = Color(0xFF000000), + surfaceBright = Color(0xFFF9FAF4), + surfaceContainer = Color(0xFFEEEEE8), + surfaceContainerHigh = Color(0xFFE8E9E3), + surfaceContainerHighest = Color(0xFFE2E3DD), + surfaceContainerLow = Color(0xFFF3F4EE), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceDim = Color(0xFFDADAD5), ) private val appDarkColors = darkColorScheme( - primary = Color(0xFF95D58E), - onPrimary = Color(0xFF00390A), - primaryContainer = Color(0xFF125219), - onPrimaryContainer = Color(0xFFB0F2A8), - secondary = Color(0xFFB9CCB4), - background = Color(0xFF191C19), - surface = Color(0xFF191C19), + primary = Color(0xFF8FD88A), + onPrimary = Color(0xFF00390B), + primaryContainer = Color(0xFF055315), + onPrimaryContainer = Color(0xFFAAF5A4), + inversePrimary = Color(0xFF2E6B30), + secondary = Color(0xFFBACCB3), + onSecondary = Color(0xFF253423), + secondaryContainer = Color(0xFF3B4B38), + onSecondaryContainer = Color(0xFFD5E8CF), + tertiary = Color(0xFFA0CFD4), + onTertiary = Color(0xFF00363B), + tertiaryContainer = Color(0xFF1F4D52), + onTertiaryContainer = Color(0xFFBCEBF1), + background = Color(0xFF121411), + onBackground = Color(0xFFE2E3DD), + surface = Color(0xFF121411), + onSurface = Color(0xFFE2E3DD), + surfaceVariant = Color(0xFF424940), + onSurfaceVariant = Color(0xFFC2C9BD), + surfaceTint = Color(0xFF8FD88A), + inverseSurface = Color(0xFFE2E3DD), + inverseOnSurface = Color(0xFF2F312D), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + outline = Color(0xFF8C9388), + outlineVariant = Color(0xFF424940), + scrim = Color(0xFF000000), + surfaceBright = Color(0xFF383A36), + surfaceContainer = Color(0xFF1E201D), + surfaceContainerHigh = Color(0xFF282B27), + surfaceContainerHighest = Color(0xFF333531), + surfaceContainerLow = Color(0xFF1A1C19), + surfaceContainerLowest = Color(0xFF0C0F0C), + surfaceDim = Color(0xFF121411), ) +/** + * Record gold: the colours of every personal-record mark. Kept beside [MaterialTheme] rather than in + * a scheme role, so records stay gold when dynamic colour is on. + */ +@Immutable +data class RecordColors( + val record: Color, + val onRecord: Color, + val recordContainer: Color, + val onRecordContainer: Color, +) + +// Tones 40/100/90/10 (light) and 80/20/30/90 (dark) of a gold palette seeded from #C9A227. +private val lightRecordColors = + RecordColors( + record = Color(0xFF755B00), + onRecord = Color(0xFFFFFFFF), + recordContainer = Color(0xFFFFE08E), + onRecordContainer = Color(0xFF241A00), + ) + +private val darkRecordColors = + RecordColors( + record = Color(0xFFECC246), + onRecord = Color(0xFF3D2E00), + recordContainer = Color(0xFF584400), + onRecordContainer = Color(0xFFFFE08E), + ) + +private val LocalRecordColors = staticCompositionLocalOf { lightRecordColors } + +/** The record gold of the current theme. */ +val MaterialTheme.recordColors: RecordColors + @Composable + @ReadOnlyComposable + get() = LocalRecordColors.current + +/** + * The app theme. Dynamic colour is off by default, so every phone shows the brand scheme; a Settings + * toggle to turn it on comes later. + */ @Composable fun GymTrackerTheme( darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, + dynamicColor: Boolean = false, content: @Composable () -> Unit, ) { val colorScheme = @@ -49,8 +157,16 @@ fun GymTrackerTheme( else -> appLightColors } - MaterialTheme( - colorScheme = colorScheme, - content = content, - ) + CompositionLocalProvider(LocalRecordColors provides if (darkTheme) darkRecordColors else lightRecordColors) { + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) + } } + +/** + * This style with tabular (equal-width) figures, so numbers that change or sit in columns, such as + * timers, set fields and totals, don't shift as their digits change. + */ +fun TextStyle.tabular(): TextStyle = copy(fontFeatureSettings = "tnum") diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/RestBar.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/RestBar.kt index 9679598..5a5e733 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/RestBar.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/RestBar.kt @@ -25,7 +25,9 @@ import androidx.compose.ui.unit.dp import io.github.prithvimk.gymtracker.R import io.github.prithvimk.gymtracker.timer.RestTimer import io.github.prithvimk.gymtracker.timer.RestTimerState +import io.github.prithvimk.gymtracker.ui.format.formatClock import io.github.prithvimk.gymtracker.ui.theme.GymTrackerTheme +import io.github.prithvimk.gymtracker.ui.theme.tabular import kotlinx.coroutines.delay /** Rest timer controls. [onAdjust] takes seconds to add, negative to take off. */ @@ -53,8 +55,8 @@ fun RestBar(rest: RestTimerState, actions: RestActions, modifier: Modifier = Mod Column(modifier = Modifier.weight(1f)) { Text( // Rounded up, so it reads 0:01 until it is really over. - text = formatElapsed(remaining + TICK_MS - 1L), - style = MaterialTheme.typography.headlineSmall, + text = formatClock(remaining + TICK_MS - 1L), + style = MaterialTheme.typography.headlineSmall.tabular(), color = MaterialTheme.colorScheme.onSecondaryContainer, ) Text( diff --git a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/SetTable.kt b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/SetTable.kt index 7e7b367..a76d43d 100644 --- a/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/SetTable.kt +++ b/app/src/main/java/io/github/prithvimk/gymtracker/ui/workout/SetTable.kt @@ -60,6 +60,9 @@ import io.github.prithvimk.gymtracker.data.db.entity.WorkoutSet import io.github.prithvimk.gymtracker.domain.matchPrevious import io.github.prithvimk.gymtracker.domain.model.PrType import io.github.prithvimk.gymtracker.domain.model.SetType +import io.github.prithvimk.gymtracker.ui.format.weightNumberText +import io.github.prithvimk.gymtracker.ui.theme.recordColors +import io.github.prithvimk.gymtracker.ui.theme.tabular /** Set edits from the logger. Every callback takes the set's id. */ @Immutable @@ -175,7 +178,7 @@ private fun SetLabelButton(set: WorkoutSet, labelText: String, actions: SetActio contentPadding = PaddingValues(0.dp), modifier = Modifier.semantics { contentDescription = description }, ) { - Text(labelText, style = MaterialTheme.typography.titleMedium) + Text(labelText, style = MaterialTheme.typography.titleMedium.tabular()) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { SetType.entries.forEach { type -> @@ -224,8 +227,8 @@ internal fun RecordBadge(types: List, modifier: Modifier = Modifier) { val description = stringResource(R.string.set_record_description, types.map { stringResource(it.badgeRes) }.joinToString()) Badge( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary, + containerColor = MaterialTheme.recordColors.record, + contentColor = MaterialTheme.recordColors.onRecord, modifier = modifier.semantics { contentDescription = description }, ) { Text(stringResource(R.string.set_record_badge)) @@ -280,7 +283,7 @@ private fun SetValueField( ) { Text( text = text, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyLarge.tabular(), color = colors.onSurface, textAlign = TextAlign.Center, maxLines = 1, @@ -324,11 +327,11 @@ private fun SetRowLayout( @Composable private fun LastTimeText(lastTime: WorkoutSet?) { val text = - lastTime?.let { stringResource(R.string.set_last_value, formatSetNumber(it.weightKg).ifEmpty { "0" }, it.reps) } + lastTime?.let { stringResource(R.string.set_last_value, weightNumberText(it.weightKg), it.reps) } ?: stringResource(R.string.set_last_none) Text( text = text, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodySmall.tabular(), color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 2, 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 5b52af2..71dcaa6 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 @@ -92,7 +92,13 @@ import io.github.prithvimk.gymtracker.timer.RestTimerState import io.github.prithvimk.gymtracker.ui.components.Stepper import io.github.prithvimk.gymtracker.ui.components.restText import io.github.prithvimk.gymtracker.ui.exercises.labelRes +import io.github.prithvimk.gymtracker.ui.format.durationText +import io.github.prithvimk.gymtracker.ui.format.formatClock +import io.github.prithvimk.gymtracker.ui.format.volumeText +import io.github.prithvimk.gymtracker.ui.format.weightText 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 kotlinx.coroutines.delay /** The Workout tab. [onEditRoutine] opens the routine editor on a routine, or on a new one for null. */ @@ -293,12 +299,12 @@ private fun WorkoutSummaryDialog( ) } if (records.isNotEmpty()) RecordsCelebration(records) - SummaryRow(stringResource(R.string.workout_summary_duration), formatElapsed(summary.durationMillis)) + SummaryRow(stringResource(R.string.workout_summary_duration), durationText(summary.durationMillis)) SummaryRow(stringResource(R.string.workout_summary_exercises), summary.exerciseCount.toString()) SummaryRow(stringResource(R.string.workout_summary_sets), summary.setCount.toString()) SummaryRow( stringResource(R.string.workout_summary_volume), - stringResource(R.string.workout_summary_volume_value, formatVolume(summary.volumeKg)), + volumeText(summary.volumeKg), ) if (summary.setCount == 0) { Text( @@ -339,22 +345,21 @@ internal fun RecordsCelebration( modifier = modifier .fillMaxWidth() .padding(bottom = 8.dp) - .background(MaterialTheme.colorScheme.tertiaryContainer, RoundedCornerShape(12.dp)) + .background(MaterialTheme.recordColors.recordContainer, RoundedCornerShape(12.dp)) .padding(12.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( painter = painterResource(R.drawable.ic_trophy), contentDescription = null, - // The container's own content colour, like the text beside it. Plain tertiary is made for - // surfaces, and on some phones' dynamic dark schemes the container is light enough to hide it. - tint = MaterialTheme.colorScheme.onTertiaryContainer, + // The container's own content colour, like the text beside it, so it reads in both themes. + tint = MaterialTheme.recordColors.onRecordContainer, modifier = Modifier.size(32.dp).scale(trophyScale.value), ) Text( text = pluralStringResource(R.plurals.workout_summary_records, count, count), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer, + color = MaterialTheme.recordColors.onRecordContainer, modifier = Modifier.padding(start = 12.dp), ) } @@ -363,13 +368,13 @@ internal fun RecordsCelebration( Text( text = exercise.exerciseName, style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, + color = MaterialTheme.recordColors.onRecordContainer, ) exercise.records.forEach { record -> Text( text = recordText(record.type, record.value, record.weightKg, record.reps), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer, + color = MaterialTheme.recordColors.onRecordContainer, ) } } @@ -380,11 +385,10 @@ internal fun RecordsCelebration( /** A record in words, e.g. "Most reps at 80 kg: 9". [weightKg] and [reps] are the set holding it. */ @Composable internal fun recordText(type: PrType, value: Double, weightKg: Double, reps: Int): String = when (type) { - PrType.WEIGHT -> stringResource(R.string.record_weight, formatSetNumber(value)) - PrType.REPS_AT_WEIGHT -> - stringResource(R.string.record_reps_at_weight, formatSetNumber(weightKg).ifEmpty { "0" }, reps) - PrType.E1RM -> stringResource(R.string.record_e1rm, formatVolume(value)) - PrType.VOLUME -> stringResource(R.string.record_volume, formatVolume(value)) + PrType.WEIGHT -> stringResource(R.string.record_weight, weightText(value)) + PrType.REPS_AT_WEIGHT -> stringResource(R.string.record_reps_at_weight, weightText(weightKg), reps) + PrType.E1RM -> stringResource(R.string.record_e1rm, volumeText(value)) + PrType.VOLUME -> stringResource(R.string.record_volume, volumeText(value)) } @Composable @@ -430,7 +434,7 @@ private fun SaveRoutineDialog( private fun SummaryRow(label: String, value: String) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - Text(value, style = MaterialTheme.typography.titleMedium) + Text(value, style = MaterialTheme.typography.titleMedium.tabular()) } } @@ -640,8 +644,8 @@ private fun ActiveContent( style = MaterialTheme.typography.headlineMedium, ) Text( - text = formatElapsed(elapsed), - style = MaterialTheme.typography.displaySmall, + text = formatClock(elapsed), + style = MaterialTheme.typography.displaySmall.tabular(), color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 4.dp), ) 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 8a70007..3fd59fd 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 @@ -23,8 +23,6 @@ import io.github.prithvimk.gymtracker.domain.WorkoutSummary import io.github.prithvimk.gymtracker.domain.model.PrType import io.github.prithvimk.gymtracker.timer.RestTimer import io.github.prithvimk.gymtracker.timer.RestTimerState -import java.text.NumberFormat -import java.util.Locale import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -312,20 +310,3 @@ class WorkoutViewModel( /** Adds [id] to the end, or removes it if it is already there. */ fun List.toggled(id: String): List = if (id in this) this - id else this + id - -/** Formats elapsed time as "m:ss", or "h:mm:ss" from one hour. Negative input counts as zero. */ -fun formatElapsed(millis: Long): String { - val totalSeconds = millis.coerceAtLeast(0L) / 1_000L - val hours = totalSeconds / 3_600L - val minutes = totalSeconds % 3_600L / 60L - val seconds = totalSeconds % 60L - return if (hours > 0) { - String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds) - } else { - String.format(Locale.ROOT, "%d:%02d", minutes, seconds) - } -} - -/** Formats volume in kg with grouping and at most one decimal, e.g. "12,345.5". */ -fun formatVolume(volumeKg: Double, locale: Locale = Locale.getDefault()): String = - NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = 1 }.format(volumeKg) diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 8d59b8e..9086bfe 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,4 +1,4 @@ - #FF191C19 + #FF121411 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index ab7b043..53cabfb 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,5 @@ - #FFFBFEFB + #FFF9FAF4 #FF1B5E20 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75bf4fd..b33bb87 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,7 +48,6 @@ Exercises Sets Volume - %1$s kg No sets were marked done, so nothing counts towards volume. Done Ended automatically after 3 hours without activity. It ends at your last activity, so the duration leaves out the idle time. @@ -65,14 +64,14 @@ Week of %1$s - %1$s · %2$s kg + %1$s · %2$s %d workout %d workouts Workout - + %1$s · %2$s · %3$s %d set @@ -106,10 +105,11 @@ %d set not done %d sets not done - Heaviest weight: %1$s kg - Most reps at %1$s kg: %2$d - Best estimated 1RM: %1$s kg - Best session volume: %1$s kg + + Heaviest weight: %1$s + Most reps at %1$s: %2$d + Best estimated 1RM: %1$s + Best session volume: %1$s Discard this workout? Everything logged in this workout will be deleted. Discard @@ -233,7 +233,7 @@ Not logged yet. Complete a set of this exercise in a workout and it shows up here. In progress - %1$s kg × %2$d + %1$s × %2$d %d rep @@ -241,7 +241,7 @@ \@ %1$s - Volume: %1$s kg + Volume: %1$s Best session volume @@ -284,4 +284,10 @@ Kettlebell Band Other + + %1$s kg + + %1$d min + %1$d h + %1$d h %2$d min diff --git a/app/src/test/java/io/github/prithvimk/gymtracker/ui/format/FormatClockTest.kt b/app/src/test/java/io/github/prithvimk/gymtracker/ui/format/FormatClockTest.kt new file mode 100644 index 0000000..d53590a --- /dev/null +++ b/app/src/test/java/io/github/prithvimk/gymtracker/ui/format/FormatClockTest.kt @@ -0,0 +1,44 @@ +package io.github.prithvimk.gymtracker.ui.format + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FormatClockTest { + @Test + fun `under an hour shows minutes and seconds`() { + assertEquals("0:00", formatClock(0L)) + assertEquals("0:59", formatClock(59_999L)) + assertEquals("12:34", formatClock(754_000L)) + } + + @Test + fun `an hour or more adds the hour`() { + assertEquals("1:00:00", formatClock(3_600_000L)) + assertEquals("1:05:09", formatClock(3_909_000L)) + } + + @Test + fun `negative time counts as zero`() { + assertEquals("0:00", formatClock(-5_000L)) + } + + @Test + fun `durations round to the nearest minute`() { + assertEquals(DurationParts(0, 58), durationParts(58 * 60_000L)) + assertEquals(DurationParts(0, 58), durationParts(58 * 60_000L + 29_999L)) + assertEquals(DurationParts(0, 59), durationParts(58 * 60_000L + 30_000L)) + assertEquals(DurationParts(0, 0), durationParts(20_000L)) + } + + @Test + fun `durations of an hour or more split into hours and minutes`() { + assertEquals(DurationParts(1, 11), durationParts(71 * 60_000L)) + assertEquals(DurationParts(1, 0), durationParts(59 * 60_000L + 45_000L)) + assertEquals(DurationParts(2, 0), durationParts(2 * 3_600_000L)) + } + + @Test + fun `negative durations count as zero`() { + assertEquals(DurationParts(0, 0), durationParts(-60_000L)) + } +} diff --git a/app/src/test/java/io/github/prithvimk/gymtracker/ui/workout/FormatVolumeTest.kt b/app/src/test/java/io/github/prithvimk/gymtracker/ui/format/FormatNumbersTest.kt similarity index 51% rename from app/src/test/java/io/github/prithvimk/gymtracker/ui/workout/FormatVolumeTest.kt rename to app/src/test/java/io/github/prithvimk/gymtracker/ui/format/FormatNumbersTest.kt index c64384b..7e550b4 100644 --- a/app/src/test/java/io/github/prithvimk/gymtracker/ui/workout/FormatVolumeTest.kt +++ b/app/src/test/java/io/github/prithvimk/gymtracker/ui/format/FormatNumbersTest.kt @@ -1,10 +1,10 @@ -package io.github.prithvimk.gymtracker.ui.workout +package io.github.prithvimk.gymtracker.ui.format import java.util.Locale import org.junit.Assert.assertEquals import org.junit.Test -class FormatVolumeTest { +class FormatNumbersTest { @Test fun `whole volumes have no decimals and are grouped`() { assertEquals("0", formatVolume(0.0, Locale.US)) @@ -12,13 +12,23 @@ class FormatVolumeTest { } @Test - fun `fractions keep one decimal`() { + fun `volumes keep one decimal`() { assertEquals("1,532.5", formatVolume(1_532.5, Locale.US)) assertEquals("100", formatVolume(100.04, Locale.US)) + assertEquals("104.5", formatVolume(104.5333, Locale.US)) + } + + @Test + fun `weights keep up to two decimals`() { + assertEquals("82.5", formatWeight(82.5, Locale.US)) + assertEquals("1.25", formatWeight(1.25, Locale.US)) + assertEquals("0", formatWeight(0.0, Locale.US)) + assertEquals("1,000", formatWeight(1_000.0, Locale.US)) } @Test fun `grouping and decimal marks follow the locale`() { assertEquals("12.345,5", formatVolume(12_345.5, Locale.GERMANY)) + assertEquals("82,5", formatWeight(82.5, Locale.GERMANY)) } } diff --git a/app/src/test/java/io/github/prithvimk/gymtracker/ui/workout/FormatElapsedTest.kt b/app/src/test/java/io/github/prithvimk/gymtracker/ui/workout/FormatElapsedTest.kt deleted file mode 100644 index fb9846f..0000000 --- a/app/src/test/java/io/github/prithvimk/gymtracker/ui/workout/FormatElapsedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.prithvimk.gymtracker.ui.workout - -import org.junit.Assert.assertEquals -import org.junit.Test - -class FormatElapsedTest { - @Test - fun `under an hour shows minutes and seconds`() { - assertEquals("0:00", formatElapsed(0L)) - assertEquals("0:59", formatElapsed(59_999L)) - assertEquals("12:34", formatElapsed(754_000L)) - } - - @Test - fun `an hour or more adds the hour`() { - assertEquals("1:00:00", formatElapsed(3_600_000L)) - assertEquals("1:05:09", formatElapsed(3_909_000L)) - } - - @Test - fun `negative time counts as zero`() { - assertEquals("0:00", formatElapsed(-5_000L)) - } -} diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index b7bcdca..cbbb6e7 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -128,11 +128,11 @@ Settled while planning, to keep the picks from contradicting each other: Goal: the whole app in one green scheme, with records in gold and numbers that read unambiguously. -- [ ] Full light and dark colour schemes generated from `#2E6B30`, defining every role including all surface containers; `dynamicColor` defaults to off (F01) -- [ ] Record colour family as an extended colour; every PR mark (logger badge, History card, exercise detail, records card) moves off tertiary to gold (F02) -- [ ] Shared formatter: finished durations in words ("58 min", "1 h 11 min"), clock format only for live timers; every weight goes through it (F03) -- [ ] Tabular figures for timers, set fields, the Last column and totals (F03) -- [ ] Shared components the later phases reuse: stat tile, gold record chip, gold number disc +- [x] Full light and dark colour schemes generated from `#2E6B30`, defining every role including all surface containers; `dynamicColor` defaults to off (F01) (Theme Builder tonal mapping, primary pinned to the brand colour) +- [x] Record colour family as an extended colour; every PR mark (logger badge, History card, exercise detail, records card) moves off tertiary to gold (F02) (`MaterialTheme.recordColors`; the calendar's selected-day ring moved off tertiary too) +- [x] Shared formatter: finished durations in words ("58 min", "1 h 11 min"), clock format only for live timers; every weight goes through it (F03) (`ui/format`; durations round to the nearest minute, and set fields still edit raw kg until Phase 5's unit setting) +- [x] Tabular figures for timers, set fields, the Last column and totals (F03) +- [x] Shared components the later phases reuse: stat tile, gold record chip, gold number disc (`ui/components/RecordMarks.kt`; the chip is on History cards already) Done when: no lavender, purple or pink is left on any screen in light or dark, and History shows "58 min" instead of "58:00".