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/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt index 765fd34d30..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 @@ -20,16 +20,18 @@ */ 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 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(), @@ -130,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 @@ -137,18 +175,58 @@ 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 { + Column(modifier = Modifier.weight(1f)) { Text( text = info.title ?: typeLabel, fontWeight = FontWeight.Bold, @@ -162,73 +240,162 @@ fun WorkoutDisplay(baseNote: Note) { ) } } + + info.source?.let { + Spacer(modifier = Modifier.width(8.dp)) + WorkoutSourceBadge(it) + } } - 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) { - 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) { + add(Stat(speed(duration, distance), speedLabel)) + } else { + 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.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.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)) + } + } } } } +/** 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, label: String, + modifier: Modifier = Modifier, ) { - Column { + Column(modifier = modifier) { Text( text = value, fontWeight = FontWeight.Bold, 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, - ) - }, ) } } 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