feat: keep only the connect prompt on the feed; workouts list in New Workout

Refine the previous change: the Workouts feed shows a connect-only Health
Connect banner (a new WorkoutConnectBanner) so permission can be granted there,
but never lists detected workouts. The detected-workout list is shown solely in
the New Workout composer's carousel, which reverts to list-only (no connect
card).

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-17 01:03:54 +00:00
parent a60d2b6c1c
commit 63aa4102d0
4 changed files with 217 additions and 84 deletions
@@ -47,6 +47,7 @@ fun FeedLoaded(
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: INav,
header: (@Composable () -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -54,6 +55,12 @@ fun FeedLoaded(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
if (header != null) {
item(key = "feed-header", contentType = "feed-header") {
header()
}
}
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
@@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
@@ -37,6 +38,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.datasource.WorkoutsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion.WorkoutConnectBanner
@Composable
fun WorkoutsScreen(
@@ -89,6 +91,18 @@ fun WorkoutsScreen(
listState = listState,
nav = nav,
routeForLastRead = "WorkoutsFeed",
// Connect-only Health Connect prompt as the first feed item. Detected
// workouts themselves are offered in the New Workout composer, not here.
onLoaded = { loaded ->
FeedLoaded(
loaded = loaded,
listState = listState,
routeForLastRead = "WorkoutsFeed",
accountViewModel = accountViewModel,
nav = nav,
header = { WorkoutConnectBanner(accountViewModel) },
)
},
)
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,7 +33,6 @@ 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.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
@@ -50,7 +48,6 @@ 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.health.connect.client.PermissionController
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
@@ -68,14 +65,13 @@ import java.time.Duration
import java.time.Instant
/**
* Health Connect integration for the New Workout composer. With permission it
* shows a horizontal list of workouts from the last
* [HealthConnectManager.LOOKBACK_DAYS] days; tapping one pre-loads the form via
* [onPick]. Without permission it shows a Connect prompt — this is the only
* place the app requests Health Connect access now that the feed banner is gone.
* Horizontal list of workouts found in Health Connect over the last
* [HealthConnectManager.LOOKBACK_DAYS] days, shown at the top of the New Workout
* composer. Tapping one pre-loads the form via [onPick].
*
* Renders nothing when Health Connect is unavailable, the suggestion setting is
* off, or (once granted) no workouts are found.
* off, permission is missing, or no workouts are found. Granting permission is
* handled by the connect prompt on the Workouts feed, not here.
*/
@Composable
fun DetectedWorkoutCarousel(
@@ -93,91 +89,45 @@ fun DetectedWorkoutCarousel(
val manager = remember { HealthConnectManager(context) }
val scope = rememberCoroutineScope()
var granted by remember { mutableStateOf<Boolean?>(null) }
var workouts by remember { mutableStateOf<List<DetectedWorkout>>(emptyList()) }
val reload: suspend () -> Unit = {
val ok = manager.hasAllPermissions()
granted = ok
workouts =
if (ok) {
val since = Instant.now().minus(Duration.ofDays(HealthConnectManager.LOOKBACK_DAYS))
manager.readNewWorkouts(since).sortedByDescending { it.startTimeEpochSeconds }
} else {
emptyList()
}
}
val permissionLauncher =
rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) {
scope.launch { reload() }
}
LifecycleResumeEffect(Unit) {
scope.launch { reload() }
scope.launch {
workouts =
if (manager.hasAllPermissions()) {
val since = Instant.now().minus(Duration.ofDays(HealthConnectManager.LOOKBACK_DAYS))
manager.readNewWorkouts(since).sortedByDescending { it.startTimeEpochSeconds }
} else {
emptyList()
}
}
onPauseOrDispose {}
}
when (granted) {
null -> return // not checked yet — render nothing so nothing flashes
false -> ConnectCard(modifier) { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }
true -> {
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)) },
)
}
}
}
}
}
}
if (workouts.isEmpty()) return
@Composable
private fun ConnectCard(
modifier: Modifier,
onConnect: () -> Unit,
) {
OutlinedCard(
Column(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.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),
) {
Text(
text = stringRes(R.string.workout_suggestion_connect_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.workout_suggestion_connect_message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(onClick = onConnect) {
Text(stringRes(R.string.workout_suggestion_connect_button))
}
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)) },
)
}
}
}
@@ -0,0 +1,162 @@
/*
* 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.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.IconButton
import androidx.compose.material3.MaterialTheme
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.saveable.rememberSaveable
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.unit.dp
import androidx.health.connect.client.PermissionController
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.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
/**
* Connect-only Health Connect prompt shown at the top of the Workouts feed. It
* invites the user to grant Health Connect access; once granted (or dismissed,
* or unavailable, or disabled in settings) it renders nothing. The actual list
* of detected workouts lives only in the New Workout composer's carousel — this
* banner never shows workouts.
*/
@Composable
fun WorkoutConnectBanner(
accountViewModel: AccountViewModel,
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 granted by remember { mutableStateOf<Boolean?>(null) }
var dismissed by rememberSaveable { mutableStateOf(false) }
val permissionLauncher =
rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) {
scope.launch { granted = manager.hasAllPermissions() }
}
LifecycleResumeEffect(Unit) {
scope.launch { granted = manager.hasAllPermissions() }
onPauseOrDispose {}
}
// Only when we know permission is missing (never during the check) and not dismissed.
if (granted != false || dismissed) return
ConnectHealthCard(
modifier = modifier,
onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) },
onDismiss = { dismissed = true },
)
}
@Composable
private fun ConnectHealthCard(
modifier: Modifier,
onConnect: () -> Unit,
onDismiss: () -> Unit,
) {
ElevatedCard(
modifier = modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 14.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(40.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
symbol = MaterialSymbols.Favorite,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Text(
text = stringRes(R.string.workout_suggestion_connect_title),
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.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
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))
}
}
}
}