feat: polish the Health Connect workout suggestion banner

Reworks the suggestion UI from a plain overlay into a designed, in-scroll card:

- Inline placement: the banner now sits above the feed (pushing it down) inside
  a Column rather than overlaying and covering the first feed items. The feed's
  scaffold top padding is applied to the Column and stripped from the feed to
  avoid doubling; the bottom padding (and the disappearing-bar animation) are
  preserved.
- Per-activity icon (run/ride/swim/hike/strength/yoga…) in a circular tinted
  badge, reusing the existing ExerciseType.symbol() mapping — no new glyphs.
- Metric chips (duration, distance, heart rate, calories, steps) in place of the
  single truncated summary line.
- Title shows the workout name or activity, subtitle shows a relative time;
  filled-tonal Share + text Dismiss actions; rounded 16dp cards.
- animateContentSize so dismissing a suggestion collapses smoothly.

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 22:51:48 +00:00
parent 1c5c6d8a6b
commit 65d7eab85c
3 changed files with 165 additions and 49 deletions
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
@@ -88,28 +90,34 @@ fun WorkoutsScreen(
},
accountViewModel = accountViewModel,
) {
Box(Modifier.fillMaxSize()) {
RefresheableBox(workoutsFeedContentState, true) {
SaveableFeedContentState(workoutsFeedContentState, scrollStateKey = ScrollStateKeys.WORKOUTS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = workoutsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "WorkoutsFeed",
)
}
}
val scaffoldPadding = LocalDisappearingScaffoldPadding.current
Column(Modifier.fillMaxSize().padding(top = scaffoldPadding.calculateTopPadding())) {
// Health Connect detection banner: invites connect or offers detected workouts as kind 1301 posts.
// It sits inline above the feed (pushing it down) instead of overlaying the first items.
WorkoutSuggestions(
accountViewModel = accountViewModel,
nav = nav,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = LocalDisappearingScaffoldPadding.current.calculateTopPadding()),
)
// The feed already merges the scaffold's top padding internally; the Column applied it
// above, so strip the top here (keeping the bottom for the bottom bar) to avoid doubling.
CompositionLocalProvider(
LocalDisappearingScaffoldPadding provides PaddingValues(bottom = scaffoldPadding.calculateBottomPadding()),
) {
Box(Modifier.weight(1f)) {
RefresheableBox(workoutsFeedContentState, true) {
SaveableFeedContentState(workoutsFeedContentState, scrollStateKey = ScrollStateKeys.WORKOUTS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = workoutsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "WorkoutsFeed",
)
}
}
}
}
}
}
}
@@ -20,16 +20,26 @@
*/
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
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -49,6 +59,7 @@ import androidx.health.connect.client.PermissionController
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout
@@ -58,6 +69,7 @@ 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
@@ -66,7 +78,8 @@ import kotlinx.coroutines.launch
* Banner shown above the Workouts feed. When Health Connect is available it
* either invites the user to connect (first run) or surfaces workouts detected
* since the last visit, each offering to open the pre-filled kind 1301 composer.
* Renders nothing on devices without Health Connect.
* Renders nothing on devices without Health Connect or when the user disabled
* the suggestion in Compose settings.
*/
@Composable
fun WorkoutSuggestions(
@@ -107,7 +120,11 @@ fun WorkoutSuggestions(
if (suggestions.isEmpty() && (hasPermission || connectDismissed)) return
Column(
modifier = modifier.fillMaxWidth().padding(horizontal = 10.dp),
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 8.dp)
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (!hasPermission && !connectDismissed) {
@@ -135,30 +152,39 @@ private fun ConnectHealthCard(
onConnect: () -> Unit,
onDismiss: () -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, top = 10.dp, bottom = 4.dp, end = 4.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 14.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ActivityBadge(MaterialSymbols.Favorite)
Text(
text = stringRes(R.string.workout_suggestion_connect_title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f).padding(start = 14.dp),
)
IconButton(onClick = onDismiss) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.workout_suggestion_dismiss),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = stringRes(R.string.workout_suggestion_connect_message),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 14.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Row(modifier = Modifier.fillMaxWidth().padding(end = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onConnect) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = onConnect) {
Text(stringRes(R.string.workout_suggestion_connect_button))
}
}
@@ -171,26 +197,24 @@ private fun WorkoutSuggestionRow(
onShare: () -> Unit,
onDismiss: () -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, top = 10.dp, end = 4.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 14.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.DirectionsRun,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
ActivityBadge(workout.exercise.symbol())
Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) {
Text(
text = workout.title ?: stringRes(R.string.workout_suggestion_detected_title),
style = MaterialTheme.typography.titleSmall,
text = workout.title ?: stringRes(workout.exercise.labelRes()),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = workout.summaryLine(),
text = relativeTime(workout.startTimeEpochSeconds),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -201,28 +225,110 @@ private fun WorkoutSuggestionRow(
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.workout_suggestion_dismiss),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(modifier = Modifier.fillMaxWidth().padding(end = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onShare) {
MetricChips(workout)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.workout_suggestion_dismiss))
}
FilledTonalButton(onClick = onShare) {
Text(stringRes(R.string.workout_suggestion_share))
}
}
}
}
/** "Running · 5.20 km · 28:14" — activity, distance (if any), then duration. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DetectedWorkout.summaryLine(): String {
val parts = mutableListOf(stringRes(exercise.labelRes()))
distanceMeters?.takeIf { it > 0 }?.let {
parts.add(stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0)))
private fun MetricChips(workout: DetectedWorkout) {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
MetricChip(MaterialSymbols.Timer, formatDuration(workout.durationSeconds))
workout.distanceMeters?.takeIf { it > 0 }?.let {
MetricChip(null, stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0)))
}
workout.avgHeartRate?.takeIf { it > 0 }?.let {
MetricChip(MaterialSymbols.Favorite, stringRes(R.string.workout_suggestion_heart_rate, it.toString()))
}
workout.calories?.takeIf { it > 0 }?.let {
MetricChip(MaterialSymbols.LocalFireDepartment, stringRes(R.string.workout_suggestion_calories, it.toString()))
}
workout.steps?.takeIf { it > 0 }?.let {
MetricChip(MaterialSymbols.DirectionsWalk, it.toString())
}
}
parts.add(formatDuration(durationSeconds))
return parts.joinToString(" · ")
}
@Composable
private fun MetricChip(
symbol: MaterialSymbol?,
text: String,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (symbol != null) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** Circular tinted badge holding the activity (or section) glyph. */
@Composable
private fun ActivityBadge(symbol: MaterialSymbol) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(40.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
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
+2
View File
@@ -708,6 +708,8 @@
<string name="workout_suggestion_share">Share</string>
<string name="workout_suggestion_dismiss">Dismiss</string>
<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="exercise_running">Running</string>
<string name="exercise_walking">Walking</string>
<string name="exercise_cycling">Cycling</string>