From 397cf7d302a705fdb38bf4ab3fefcba3d01326bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 23:46:11 +0000 Subject: [PATCH] feat: add a recent-workouts carousel to the New Workout composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P --- .../loggedIn/workouts/NewWorkoutScreen.kt | 14 +- .../loggedIn/workouts/NewWorkoutViewModel.kt | 12 +- .../suggestion/DetectedWorkoutCarousel.kt | 205 ++++++++++++++++++ .../suggestion/WorkoutSuggestionCard.kt | 41 +--- .../suggestion/WorkoutSuggestionShared.kt | 62 ++++++ amethyst/src/main/res/values/strings.xml | 1 + 6 files changed, 292 insertions(+), 43 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt index fd1823d00e..2069be47a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt index 969912f317..df5d5ce98f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt new file mode 100644 index 0000000000..0023ceb85c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt @@ -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>(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() + 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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt index 6e2c570ece..b86e3e3129 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt @@ -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, - ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt new file mode 100644 index 0000000000..4e6d0be653 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt @@ -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() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 765ff9c146..eb691a1ee7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -710,6 +710,7 @@ %1$s km %1$s bpm %1$s kcal + From Health Connect Running Walking Cycling