mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+12
-2
@@ -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(
|
||||
|
||||
+10
-2
@@ -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 }
|
||||
|
||||
|
||||
+205
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-39
@@ -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,
|
||||
)
|
||||
|
||||
+62
@@ -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()
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user