feat: add a recent-workouts carousel to the New Workout composer

Show a horizontal list of workouts found in Health Connect over the last 7 days
at the top of the New Workout screen; tapping one pre-loads the form (activity,
duration, distance, calories, heart rate, steps, elevation, start time).

Unlike the feed banner, the carousel shows every workout in the window (not
filtered by what was already shared/dismissed) and re-reads on resume. The
DetectedWorkout→Route mapping and duration/relative-time formatting are
extracted to a shared file so the banner and carousel stay in sync, and the
ViewModel gains applyPrefill() to overwrite fields on tap (vs the once-only
prefill used for the nav argument).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
This commit is contained in:
Claude
2026-06-16 23:46:11 +00:00
parent 7bad2a09b3
commit 397cf7d302
6 changed files with 292 additions and 43 deletions
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion.DetectedWorkoutCarousel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
@@ -95,13 +96,16 @@ fun NewWorkoutScreen(
.consumeWindowInsets(pad)
.imePadding(),
) {
NewWorkoutBody(postViewModel)
NewWorkoutBody(postViewModel, accountViewModel)
}
}
}
@Composable
private fun NewWorkoutBody(postViewModel: NewWorkoutViewModel) {
private fun NewWorkoutBody(
postViewModel: NewWorkoutViewModel,
accountViewModel: AccountViewModel,
) {
Column(
modifier =
Modifier
@@ -110,6 +114,12 @@ private fun NewWorkoutBody(postViewModel: NewWorkoutViewModel) {
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// Tap a recent Health Connect workout to pre-load the form.
DetectedWorkoutCarousel(
accountViewModel = accountViewModel,
onPick = { postViewModel.applyPrefill(it) },
)
ExerciseTypeSelector(postViewModel.exercise) { postViewModel.exercise = it }
OutlinedTextField(
@@ -76,13 +76,21 @@ class NewWorkoutViewModel : ViewModel() {
/**
* Pre-fills the form from a [Route.NewWorkout] (e.g. a Health Connect
* detection). Applied once per ViewModel so user edits are not overwritten
* on recomposition; a blank route leaves the empty manual form untouched.
* detection) the first time only, so user edits are not overwritten on
* recomposition; a blank route leaves the empty manual form untouched.
*/
fun prefill(route: Route.NewWorkout) {
if (hasPrefilled) return
hasPrefilled = true
applyPrefill(route)
}
/**
* Unconditionally fills the form from [route]. Used when the user taps a
* workout in the New Workout carousel and expects the fields to switch to
* that workout, overwriting whatever was there.
*/
fun applyPrefill(route: Route.NewWorkout) {
route.exercise?.let { ExerciseType.parse(it) }?.let { exercise = it }
route.title?.let { title = it }
@@ -0,0 +1,205 @@
/*
* 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.suggestion
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectStore
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.labelRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.symbol
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
import java.time.Duration
import java.time.Instant
/**
* Horizontal list of workouts found in Health Connect in the last
* [HealthConnectStore.LOOKBACK_DAYS] days, shown at the top of the New Workout
* composer. Tapping one pre-loads the form with that workout via [onPick].
*
* Renders nothing when Health Connect is unavailable, the suggestion setting is
* off, permission is missing, or no workouts are found — so the manual form is
* untouched in those cases. Unlike the feed banner, this shows every workout in
* the window (it is not filtered by what was already shared or dismissed).
*/
@Composable
fun DetectedWorkoutCarousel(
accountViewModel: AccountViewModel,
onPick: (Route.NewWorkout) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val available = remember { HealthConnectManager.isAvailable(context) }
if (!available) return
val enabled by accountViewModel.settings.uiSettingsFlow.suggestWorkoutsFromHealthConnect
.collectAsStateWithLifecycle()
if (enabled == BooleanType.NEVER) return
val manager = remember { HealthConnectManager(context) }
val scope = rememberCoroutineScope()
var workouts by remember { mutableStateOf<List<DetectedWorkout>>(emptyList()) }
LifecycleResumeEffect(Unit) {
scope.launch {
workouts =
if (manager.hasAllPermissions()) {
val since = Instant.now().minus(Duration.ofDays(HealthConnectStore.LOOKBACK_DAYS))
manager.readNewWorkouts(since).sortedByDescending { it.startTimeEpochSeconds }
} else {
emptyList()
}
}
onPauseOrDispose {}
}
if (workouts.isEmpty()) return
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringRes(R.string.workout_from_health_connect),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 2.dp),
) {
items(workouts, key = { it.id }) { workout ->
val label = workout.title ?: stringRes(workout.exercise.labelRes())
WorkoutChip(
workout = workout,
label = label,
summary = summaryLine(workout),
onClick = { onPick(workout.toNewWorkoutRoute(label)) },
)
}
}
}
}
@Composable
private fun summaryLine(workout: DetectedWorkout): String {
val parts = mutableListOf<String>()
workout.distanceMeters?.takeIf { it > 0 }?.let {
parts.add(stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0)))
}
parts.add(formatWorkoutDuration(workout.durationSeconds))
return parts.joinToString(" · ")
}
@Composable
private fun WorkoutChip(
workout: DetectedWorkout,
label: String,
summary: String,
onClick: () -> Unit,
) {
OutlinedCard(
onClick = onClick,
modifier = Modifier.width(150.dp),
shape = RoundedCornerShape(14.dp),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
symbol = workout.exercise.symbol(),
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = workoutRelativeTime(workout.startTimeEpochSeconds),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
@@ -66,12 +65,10 @@ import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectStore
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.labelRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.symbol
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag
import kotlinx.coroutines.launch
/**
@@ -229,7 +226,7 @@ private fun WorkoutSuggestionRow(
overflow = TextOverflow.Ellipsis,
)
Text(
text = relativeTime(workout.startTimeEpochSeconds),
text = workoutRelativeTime(workout.startTimeEpochSeconds),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -270,7 +267,7 @@ private fun MetricChips(workout: DetectedWorkout) {
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
MetricChip(MaterialSymbols.Timer, formatDuration(workout.durationSeconds))
MetricChip(MaterialSymbols.Timer, formatWorkoutDuration(workout.durationSeconds))
workout.distanceMeters?.takeIf { it > 0 }?.let {
MetricChip(null, stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0)))
}
@@ -335,37 +332,3 @@ private fun ActivityBadge(symbol: MaterialSymbol) {
}
}
}
private fun relativeTime(epochSeconds: Long): String =
DateUtils
.getRelativeTimeSpanString(
epochSeconds * 1000L,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
private fun formatDuration(totalSeconds: Long): String {
val h = totalSeconds / 3600
val m = (totalSeconds % 3600) / 60
val s = totalSeconds % 60
return if (h > 0) {
"%d:%02d:%02d".format(h, m, s)
} else {
"%d:%02d".format(m, s)
}
}
private fun DetectedWorkout.toNewWorkoutRoute(title: String) =
Route.NewWorkout(
exercise = exercise.code,
title = title,
durationSeconds = durationSeconds,
distanceMeters = distanceMeters ?: 0.0,
calories = calories ?: 0,
avgHeartRate = avgHeartRate ?: 0,
maxHeartRate = maxHeartRate ?: 0,
steps = steps ?: 0,
elevationGainMeters = elevationGainMeters ?: 0.0,
startTime = startTimeEpochSeconds,
source = SourceTag.HEALTH_CONNECT,
)
@@ -0,0 +1,62 @@
/*
* 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.suggestion
import android.text.format.DateUtils
import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag
/** Builds the pre-filled composer route for a detected workout. Shared by the
* feed suggestion banner and the New Workout carousel so they never drift. */
internal fun DetectedWorkout.toNewWorkoutRoute(title: String) =
Route.NewWorkout(
exercise = exercise.code,
title = title,
durationSeconds = durationSeconds,
distanceMeters = distanceMeters ?: 0.0,
calories = calories ?: 0,
avgHeartRate = avgHeartRate ?: 0,
maxHeartRate = maxHeartRate ?: 0,
steps = steps ?: 0,
elevationGainMeters = elevationGainMeters ?: 0.0,
startTime = startTimeEpochSeconds,
source = SourceTag.HEALTH_CONNECT,
)
internal fun formatWorkoutDuration(totalSeconds: Long): String {
val h = totalSeconds / 3600
val m = (totalSeconds % 3600) / 60
val s = totalSeconds % 60
return if (h > 0) {
"%d:%02d:%02d".format(h, m, s)
} else {
"%d:%02d".format(m, s)
}
}
internal fun workoutRelativeTime(epochSeconds: Long): String =
DateUtils
.getRelativeTimeSpanString(
epochSeconds * 1000L,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
+1
View File
@@ -710,6 +710,7 @@
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_suggestion_heart_rate">%1$s bpm</string>
<string name="workout_suggestion_calories">%1$s kcal</string>
<string name="workout_from_health_connect">From Health Connect</string>
<string name="exercise_running">Running</string>
<string name="exercise_walking">Walking</string>
<string name="exercise_cycling">Cycling</string>