mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
+26
-18
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+137
-31
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user