feat: move Health Connect entirely into the New Workout composer

Per request, the Workouts feed no longer has any Health Connect code. Both the
permission prompt and the detected-workout list now live in the New Workout
carousel: it shows a Connect card when permission is missing and the workout
list once granted.

- Remove WorkoutConnectBanner and the feed wiring; revert the FeedLoaded header
  slot that fed it.
- Restore the Connect prompt in DetectedWorkoutCarousel (tri-state permission:
  unknown renders nothing, denied shows Connect, granted shows the list).

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:15:51 +00:00
parent 63aa4102d0
commit ee1a7aec2f
4 changed files with 107 additions and 220 deletions
@@ -47,7 +47,6 @@ fun FeedLoaded(
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: INav,
header: (@Composable () -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -55,12 +54,6 @@ 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,7 +25,6 @@ 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
@@ -38,7 +37,6 @@ 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(
@@ -91,18 +89,6 @@ 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,6 +20,7 @@
*/
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
@@ -33,6 +34,7 @@ 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
@@ -48,10 +50,12 @@ 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
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.DetectedWorkout
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
@@ -65,13 +69,14 @@ import java.time.Duration
import java.time.Instant
/**
* 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].
* Health Connect integration for the New Workout composer — the single place the
* app touches Health Connect. Without permission it shows a Connect prompt; with
* permission it shows a horizontal list of workouts from the last
* [HealthConnectManager.LOOKBACK_DAYS] days, and tapping one pre-loads the form
* via [onPick].
*
* Renders nothing when Health Connect is unavailable, the suggestion setting is
* off, permission is missing, or no workouts are found. Granting permission is
* handled by the connect prompt on the Workouts feed, not here.
* off, or (once granted) no workouts are found.
*/
@Composable
fun DetectedWorkoutCarousel(
@@ -89,45 +94,110 @@ 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()) }
LifecycleResumeEffect(Unit) {
scope.launch {
workouts =
if (manager.hasAllPermissions()) {
val since = Instant.now().minus(Duration.ofDays(HealthConnectManager.LOOKBACK_DAYS))
manager.readNewWorkouts(since).sortedByDescending { it.startTimeEpochSeconds }
} else {
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() }
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)) },
when (granted) {
null -> return // not checked yet — render nothing so the prompt never 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)) },
)
}
}
}
}
}
}
@Composable
private fun ConnectCard(
modifier: Modifier,
onConnect: () -> Unit,
) {
OutlinedCard(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, top = 14.dp, end = 14.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,
)
}
}
Column(modifier = Modifier.padding(start = 12.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().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
) {
Button(onClick = onConnect) {
Text(stringRes(R.string.workout_suggestion_connect_button))
}
}
}
@@ -1,162 +0,0 @@
/*
* 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))
}
}
}
}