From ee1a7aec2f52db79deaf50c54a29ea1bd163e814 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 01:15:51 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P --- .../amethyst/ui/feeds/FeedLoaded.kt | 7 - .../loggedIn/workouts/WorkoutsScreen.kt | 14 -- .../suggestion/DetectedWorkoutCarousel.kt | 144 ++++++++++++---- .../suggestion/WorkoutConnectBanner.kt | 162 ------------------ 4 files changed, 107 insertions(+), 220 deletions(-) delete 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 31c1c2d330..1fe04f30f3 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,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 }, 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 e017b4acb3..c4d562485f 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,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) }, - ) - }, ) } } 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 cae07199d0..146472d60b 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,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(null) } var workouts by remember { mutableStateOf>(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)) } } } 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 deleted file mode 100644 index 33640a30de..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutConnectBanner.kt +++ /dev/null @@ -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(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)) - } - } - } -}