From 63aa4102d02f20ef9f2f5daa5e7ed22f00ae3d53 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 01:03:54 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P --- .../amethyst/ui/feeds/FeedLoaded.kt | 7 + .../loggedIn/workouts/WorkoutsScreen.kt | 14 ++ .../suggestion/DetectedWorkoutCarousel.kt | 118 ++++--------- .../suggestion/WorkoutConnectBanner.kt | 162 ++++++++++++++++++ 4 files changed, 217 insertions(+), 84 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutConnectBanner.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt index 1fe04f30f3..31c1c2d330 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedLoaded.kt @@ -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 }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt index c4d562485f..e017b4acb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt @@ -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) }, + ) + }, ) } } 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 index 7887040451..cae07199d0 100644 --- 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 @@ -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(null) } var workouts by remember { mutableStateOf>(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)) }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutConnectBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutConnectBanner.kt new file mode 100644 index 0000000000..33640a30de --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutConnectBanner.kt @@ -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(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)) + } + } + } +}