From 988af90763a1f9ba16f3fd14b1175ecd08b3fbbf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 22:43:56 +0000 Subject: [PATCH 1/3] feat: render Workout feed with regular NoteCompose Switch the Workouts screen from the custom WorkoutCardCompose card to the standard NoteCompose feed via the default FeedLoaded renderer. NoteCompose already dispatches WorkoutRecordEvent to WorkoutDisplay, so workouts render with the full note chrome (author header, reactions, replies, etc.). Removes the now-unused WorkoutFeedLoaded and WorkoutCardCompose. --- .../loggedIn/workouts/WorkoutCardCompose.kt | 73 ------------------ .../loggedIn/workouts/WorkoutFeedLoaded.kt | 75 ------------------- .../loggedIn/workouts/WorkoutsScreen.kt | 8 -- 3 files changed, 156 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt deleted file mode 100644 index 8c60b9fc3b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.note.ReactionsRow -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader -import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent - -@Composable -fun WorkoutCardCompose( - baseNote: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val event = (baseNote.event as? WorkoutRecordEvent) ?: return - - Column( - modifier = - Modifier.fillMaxWidth().clickable { - routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) } - }, - ) { - UserCardHeader(baseNote, accountViewModel, nav) - - WorkoutDisplay(baseNote) - - if (event.content.isNotBlank()) { - Text( - text = event.content, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), - ) - } - - ReactionsRow( - baseNote = baseNote, - showReactionDetail = true, - addPadding = true, - editState = null, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt deleted file mode 100644 index c496e9d54e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent - -@Composable -fun WorkoutFeedLoaded( - loaded: FeedState.Loaded, - listState: LazyListState, - accountViewModel: AccountViewModel, - nav: INav, -) { - val items by loaded.feed.collectAsStateWithLifecycle() - - LazyColumn( - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - itemsIndexed( - items.list, - key = { _, item -> item.idHex }, - contentType = { _, item -> item.event?.kind ?: -1 }, - ) { _, item -> - if (item.event is WorkoutRecordEvent) { - WorkoutCardCompose( - baseNote = item, - accountViewModel = accountViewModel, - nav = nav, - ) - - HorizontalDivider( - thickness = DividerThickness, - ) - - Spacer(modifier = Modifier.height(8.dp)) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt index 08769e1980..c4d562485f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt @@ -89,14 +89,6 @@ fun WorkoutsScreen( listState = listState, nav = nav, routeForLastRead = "WorkoutsFeed", - onLoaded = { loaded -> - WorkoutFeedLoaded( - loaded = loaded, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) - }, ) } } From e39369da8076077b291c8327f9fdc238655970a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 22:47:35 +0000 Subject: [PATCH 2/3] feat: enrich workout display with source, splits-style metrics Surface more of the parsed kind-1301 data in WorkoutDisplay, modeled on how RUNSTR renders workout records: - Source badge (GPS / RUNSTR / HEALTHKIT / MANUAL) in the header - Average speed (km/h or mph) for cycling instead of pace - Elevation loss alongside elevation gain - Max heart rate alongside average heart rate Relabels 'Elevation' to 'Elevation gain' now that loss is shown. --- .../loggedIn/workouts/WorkoutDisplay.kt | 72 +++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 5 +- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt index 765fd34d30..8927c2b443 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -30,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,6 +39,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -53,6 +56,7 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.Elevation import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag import kotlin.math.abs +import kotlin.math.round fun ExerciseType?.symbol(): MaterialSymbol = when (this) { @@ -95,18 +99,39 @@ private fun paceMinPerUnit( return "${secondsPerUnit / 60}:${(secondsPerUnit % 60).toString().padStart(2, '0')}" } +/** Average speed, more natural than pace for wheeled/water sports. Returns e.g. `24.5 km/h` or `15.2 mph`. */ +private fun speed( + durationSeconds: Long, + distance: DistanceTag, +): String { + val hours = durationSeconds / 3600.0 + return if (distance.unit == DistanceTag.MILES) { + "${trimToOneDecimal(distance.value / hours)} mph" + } else { + "${trimToOneDecimal(distance.toKilometers() / hours)} km/h" + } +} + +private fun trimToOneDecimal(value: Double): String { + val rounded = round(value * 10.0) / 10.0 + return rounded.trimmed() +} + /** One-shot snapshot of the parsed workout tags, so the feed doesn't re-scan the tag array on every recomposition. */ @Immutable class WorkoutInfo( val title: String?, val type: ExerciseType?, val exerciseRaw: String?, + val source: String?, val durationSeconds: Long?, val distance: DistanceTag?, val elevationGain: Elevation?, + val elevationLoss: Elevation?, val calories: Int?, val steps: Int?, val avgHeartRate: Int?, + val maxHeartRate: Int?, val sets: Int?, val reps: Int?, val weight: WeightTag?, @@ -117,12 +142,15 @@ class WorkoutInfo( title = event.title(), type = event.exerciseType(), exerciseRaw = event.exercise(), + source = event.workoutSource(), durationSeconds = event.durationSeconds(), distance = event.distance(), elevationGain = event.elevationGain(), + elevationLoss = event.elevationLoss(), calories = event.calories(), steps = event.steps(), avgHeartRate = event.avgHeartRate(), + maxHeartRate = event.maxHeartRate(), sets = event.sets(), reps = event.reps(), weight = event.weight(), @@ -148,7 +176,7 @@ fun WorkoutDisplay(baseNote: Note) { Spacer(modifier = Modifier.width(8.dp)) - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = info.title ?: typeLabel, fontWeight = FontWeight.Bold, @@ -162,6 +190,11 @@ fun WorkoutDisplay(baseNote: Note) { ) } } + + info.source?.let { + Spacer(modifier = Modifier.width(8.dp)) + WorkoutSourceBadge(it) + } } WorkoutStatsRow(info) @@ -187,16 +220,25 @@ private fun WorkoutStatsRow(info: WorkoutInfo) { } if (duration != null && distance != null && distance.value > 0.0) { - WorkoutStat( - "${paceMinPerUnit(duration, distance.value)} /${distance.unit}", - stringRes(R.string.workout_pace), - ) + // Cycling is conventionally reported as speed; running/walking/etc. as pace. + if (info.type == ExerciseType.CYCLING) { + WorkoutStat(speed(duration, distance), stringRes(R.string.workout_speed)) + } else { + WorkoutStat( + "${paceMinPerUnit(duration, distance.value)} /${distance.unit}", + stringRes(R.string.workout_pace), + ) + } } info.elevationGain?.let { WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation)) } + info.elevationLoss?.let { + WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation_loss)) + } + info.calories?.let { WorkoutStat("$it kcal", stringRes(R.string.workout_calories)) } @@ -209,6 +251,10 @@ private fun WorkoutStatsRow(info: WorkoutInfo) { WorkoutStat("$it bpm", stringRes(R.string.workout_heart_rate)) } + info.maxHeartRate?.let { + WorkoutStat("$it bpm", stringRes(R.string.workout_max_heart_rate)) + } + info.sets?.let { WorkoutStat("$it", stringRes(R.string.workout_sets)) } @@ -223,6 +269,22 @@ private fun WorkoutStatsRow(info: WorkoutInfo) { } } +/** Small chip showing how the workout was recorded (e.g. GPS, RUNSTR, HEALTHKIT, MANUAL). */ +@Composable +private fun WorkoutSourceBadge(source: String) { + Text( + text = source.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + @Composable private fun WorkoutStat( value: String, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4a6e63b466..86d82fcc06 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -687,10 +687,13 @@ Duration Distance Pace - Elevation + Speed + Elevation gain + Elevation loss Calories Steps Heart rate + Max heart rate Sets Reps Weight From 830e340ed80874f8ca26c23af31de981d6aa4e5b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 00:14:44 +0000 Subject: [PATCH 3/3] feat: hero metric and fixed grid for workout display Make the workout note render with more visual punch, modeled on RUNSTR's workout cards: - Promote the headline metric (distance for cardio, steps when there is no distance, otherwise duration) to a large hero number, skipping it in the grid below so it is not repeated. - Lay out secondary metrics in a fixed 3-column grid instead of a free-flowing row so values line up in tidy columns. - Give the activity icon a tinted circular chip for more prominence. --- .../loggedIn/workouts/WorkoutDisplay.kt | 227 +++++++++++++----- 1 file changed, 166 insertions(+), 61 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt index 8927c2b443..a43ee9645a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt @@ -22,15 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts 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.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -158,6 +158,16 @@ class WorkoutInfo( } } +/** Which metric is promoted to the hero number, so the grid below can skip repeating it. */ +private enum class HeroKind { DISTANCE, STEPS, DURATION, NONE } + +/** A single secondary metric: a bold value over a muted label. */ +@Immutable +private class Stat( + val value: String, + val label: String, +) + @Composable fun WorkoutDisplay(baseNote: Note) { val event = (baseNote.event as? WorkoutRecordEvent) ?: return @@ -165,16 +175,56 @@ fun WorkoutDisplay(baseNote: Note) { val info = remember(baseNote) { WorkoutInfo.from(event) } val typeLabel = info.type?.let { stringRes(it.labelRes()) } ?: info.exerciseRaw ?: stringRes(R.string.workout) + val duration = info.durationSeconds + val distance = info.distance + val steps = info.steps + + val heroKind = + when { + distance != null && distance.value > 0.0 -> HeroKind.DISTANCE + steps != null -> HeroKind.STEPS + duration != null -> HeroKind.DURATION + else -> HeroKind.NONE + } + + val secondaryStats = + buildSecondaryStats( + info = info, + heroKind = heroKind, + durationLabel = stringRes(R.string.workout_duration), + distanceLabel = stringRes(R.string.workout_distance), + paceLabel = stringRes(R.string.workout_pace), + speedLabel = stringRes(R.string.workout_speed), + elevationGainLabel = stringRes(R.string.workout_elevation), + elevationLossLabel = stringRes(R.string.workout_elevation_loss), + caloriesLabel = stringRes(R.string.workout_calories), + stepsLabel = stringRes(R.string.workout_steps), + heartRateLabel = stringRes(R.string.workout_heart_rate), + maxHeartRateLabel = stringRes(R.string.workout_max_heart_rate), + setsLabel = stringRes(R.string.workout_sets), + repsLabel = stringRes(R.string.workout_reps), + weightLabel = stringRes(R.string.workout_weight), + ) + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - symbol = info.type.symbol(), - contentDescription = typeLabel, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.primary, - ) + Box( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = info.type.symbol(), + contentDescription = typeLabel, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Text( @@ -197,74 +247,128 @@ fun WorkoutDisplay(baseNote: Note) { } } - WorkoutStatsRow(info) + when (heroKind) { + HeroKind.DISTANCE -> + WorkoutHero(distance!!.value.trimmed(), distance.unit, stringRes(R.string.workout_distance)) + HeroKind.STEPS -> + WorkoutHero(steps!!.toString(), null, stringRes(R.string.workout_steps)) + HeroKind.DURATION -> + WorkoutHero(DurationTag.formatTime(duration!!), null, stringRes(R.string.workout_duration)) + HeroKind.NONE -> {} + } + + WorkoutStatsGrid(secondaryStats) } } -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun WorkoutStatsRow(info: WorkoutInfo) { +/** Builds the ordered list of secondary metrics, skipping whichever one is shown as the hero. */ +private fun buildSecondaryStats( + info: WorkoutInfo, + heroKind: HeroKind, + durationLabel: String, + distanceLabel: String, + paceLabel: String, + speedLabel: String, + elevationGainLabel: String, + elevationLossLabel: String, + caloriesLabel: String, + stepsLabel: String, + heartRateLabel: String, + maxHeartRateLabel: String, + setsLabel: String, + repsLabel: String, + weightLabel: String, +): List { val duration = info.durationSeconds val distance = info.distance - FlowRow( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - ) { - duration?.let { - WorkoutStat(DurationTag.formatTime(it), stringRes(R.string.workout_duration)) + return buildList { + if (heroKind != HeroKind.DURATION) { + duration?.let { add(Stat(DurationTag.formatTime(it), durationLabel)) } } - - distance?.let { - WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_distance)) + if (heroKind != HeroKind.DISTANCE) { + distance?.let { add(Stat("${it.value.trimmed()} ${it.unit}", distanceLabel)) } } - if (duration != null && distance != null && distance.value > 0.0) { // Cycling is conventionally reported as speed; running/walking/etc. as pace. if (info.type == ExerciseType.CYCLING) { - WorkoutStat(speed(duration, distance), stringRes(R.string.workout_speed)) + add(Stat(speed(duration, distance), speedLabel)) } else { - WorkoutStat( - "${paceMinPerUnit(duration, distance.value)} /${distance.unit}", - stringRes(R.string.workout_pace), + add(Stat("${paceMinPerUnit(duration, distance.value)} /${distance.unit}", paceLabel)) + } + } + info.elevationGain?.let { add(Stat("${it.value.trimmed()} ${it.unit}", elevationGainLabel)) } + info.elevationLoss?.let { add(Stat("${it.value.trimmed()} ${it.unit}", elevationLossLabel)) } + info.calories?.let { add(Stat("$it kcal", caloriesLabel)) } + if (heroKind != HeroKind.STEPS) { + info.steps?.let { add(Stat("$it", stepsLabel)) } + } + info.avgHeartRate?.let { add(Stat("$it bpm", heartRateLabel)) } + info.maxHeartRate?.let { add(Stat("$it bpm", maxHeartRateLabel)) } + info.sets?.let { add(Stat("$it", setsLabel)) } + info.reps?.let { add(Stat("$it", repsLabel)) } + info.weight?.let { add(Stat("${it.value.trimmed()} ${it.unit}", weightLabel)) } + } +} + +/** The headline metric, shown large above the secondary grid (e.g. `5.2 km`). */ +@Composable +private fun WorkoutHero( + value: String, + unit: String?, + label: String, +) { + Column(modifier = Modifier.padding(top = 10.dp)) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = value, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary, + ) + unit?.let { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(bottom = 6.dp), ) } } + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } +} - info.elevationGain?.let { - WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation)) - } +/** Fixed-column grid so secondary metrics line up in tidy columns instead of free-flowing. */ +@Composable +private fun WorkoutStatsGrid( + stats: List, + columns: Int = 3, +) { + if (stats.isEmpty()) return - info.elevationLoss?.let { - WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation_loss)) - } - - info.calories?.let { - WorkoutStat("$it kcal", stringRes(R.string.workout_calories)) - } - - info.steps?.let { - WorkoutStat("$it", stringRes(R.string.workout_steps)) - } - - info.avgHeartRate?.let { - WorkoutStat("$it bpm", stringRes(R.string.workout_heart_rate)) - } - - info.maxHeartRate?.let { - WorkoutStat("$it bpm", stringRes(R.string.workout_max_heart_rate)) - } - - info.sets?.let { - WorkoutStat("$it", stringRes(R.string.workout_sets)) - } - - info.reps?.let { - WorkoutStat("$it", stringRes(R.string.workout_reps)) - } - - info.weight?.let { - WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_weight)) + Column( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + stats.chunked(columns).forEach { rowStats -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + rowStats.forEach { stat -> + WorkoutStat(stat.value, stat.label, Modifier.weight(1f)) + } + // Pad the last row so columns stay aligned across rows. + repeat(columns - rowStats.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } } } } @@ -289,8 +393,9 @@ private fun WorkoutSourceBadge(source: String) { private fun WorkoutStat( value: String, label: String, + modifier: Modifier = Modifier, ) { - Column { + Column(modifier = modifier) { Text( text = value, fontWeight = FontWeight.Bold,