diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt index fd1823d00e..2069be47a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion.DetectedWorkoutCarousel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType @@ -95,13 +96,16 @@ fun NewWorkoutScreen( .consumeWindowInsets(pad) .imePadding(), ) { - NewWorkoutBody(postViewModel) + NewWorkoutBody(postViewModel, accountViewModel) } } } @Composable -private fun NewWorkoutBody(postViewModel: NewWorkoutViewModel) { +private fun NewWorkoutBody( + postViewModel: NewWorkoutViewModel, + accountViewModel: AccountViewModel, +) { Column( modifier = Modifier @@ -110,6 +114,12 @@ private fun NewWorkoutBody(postViewModel: NewWorkoutViewModel) { .padding(horizontal = 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + // Tap a recent Health Connect workout to pre-load the form. + DetectedWorkoutCarousel( + accountViewModel = accountViewModel, + onPick = { postViewModel.applyPrefill(it) }, + ) + ExerciseTypeSelector(postViewModel.exercise) { postViewModel.exercise = it } OutlinedTextField( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt index 969912f317..df5d5ce98f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt @@ -76,13 +76,21 @@ class NewWorkoutViewModel : ViewModel() { /** * Pre-fills the form from a [Route.NewWorkout] (e.g. a Health Connect - * detection). Applied once per ViewModel so user edits are not overwritten - * on recomposition; a blank route leaves the empty manual form untouched. + * detection) the first time only, so user edits are not overwritten on + * recomposition; a blank route leaves the empty manual form untouched. */ fun prefill(route: Route.NewWorkout) { if (hasPrefilled) return hasPrefilled = true + applyPrefill(route) + } + /** + * Unconditionally fills the form from [route]. Used when the user taps a + * workout in the New Workout carousel and expects the fields to switch to + * that workout, overwriting whatever was there. + */ + fun applyPrefill(route: Route.NewWorkout) { route.exercise?.let { ExerciseType.parse(it) }?.let { exercise = it } route.title?.let { title = it } 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 new file mode 100644 index 0000000000..0023ceb85c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt @@ -0,0 +1,205 @@ +/* + * 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.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.layout.width +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.MaterialTheme +import androidx.compose.material3.OutlinedCard +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.setValue +import androidx.compose.ui.Alignment +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.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.model.BooleanType +import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout +import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager +import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectStore +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 kotlinx.coroutines.launch +import java.time.Duration +import java.time.Instant + +/** + * Horizontal list of workouts found in Health Connect in the last + * [HealthConnectStore.LOOKBACK_DAYS] days, shown at the top of the New Workout + * composer. Tapping one pre-loads the form with that workout via [onPick]. + * + * Renders nothing when Health Connect is unavailable, the suggestion setting is + * off, permission is missing, or no workouts are found — so the manual form is + * untouched in those cases. Unlike the feed banner, this shows every workout in + * the window (it is not filtered by what was already shared or dismissed). + */ +@Composable +fun DetectedWorkoutCarousel( + accountViewModel: AccountViewModel, + onPick: (Route.NewWorkout) -> Unit, + 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 workouts by remember { mutableStateOf>(emptyList()) } + + LifecycleResumeEffect(Unit) { + scope.launch { + workouts = + if (manager.hasAllPermissions()) { + val since = Instant.now().minus(Duration.ofDays(HealthConnectStore.LOOKBACK_DAYS)) + manager.readNewWorkouts(since).sortedByDescending { it.startTimeEpochSeconds } + } else { + emptyList() + } + } + 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)) }, + ) + } + } + } +} + +@Composable +private fun summaryLine(workout: DetectedWorkout): String { + val parts = mutableListOf() + workout.distanceMeters?.takeIf { it > 0 }?.let { + parts.add(stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0))) + } + parts.add(formatWorkoutDuration(workout.durationSeconds)) + return parts.joinToString(" · ") +} + +@Composable +private fun WorkoutChip( + workout: DetectedWorkout, + label: String, + summary: String, + onClick: () -> Unit, +) { + OutlinedCard( + onClick = onClick, + modifier = Modifier.width(150.dp), + shape = RoundedCornerShape(14.dp), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + symbol = workout.exercise.symbol(), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = workoutRelativeTime(workout.startTimeEpochSeconds), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt index 6e2c570ece..b86e3e3129 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt @@ -20,7 +20,6 @@ */ 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 @@ -66,12 +65,10 @@ import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectStore 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 /** @@ -229,7 +226,7 @@ private fun WorkoutSuggestionRow( overflow = TextOverflow.Ellipsis, ) Text( - text = relativeTime(workout.startTimeEpochSeconds), + text = workoutRelativeTime(workout.startTimeEpochSeconds), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -270,7 +267,7 @@ private fun MetricChips(workout: DetectedWorkout) { horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - MetricChip(MaterialSymbols.Timer, formatDuration(workout.durationSeconds)) + MetricChip(MaterialSymbols.Timer, formatWorkoutDuration(workout.durationSeconds)) workout.distanceMeters?.takeIf { it > 0 }?.let { MetricChip(null, stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0))) } @@ -335,37 +332,3 @@ private fun ActivityBadge(symbol: MaterialSymbol) { } } } - -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 - val s = totalSeconds % 60 - return if (h > 0) { - "%d:%02d:%02d".format(h, m, s) - } else { - "%d:%02d".format(m, s) - } -} - -private fun DetectedWorkout.toNewWorkoutRoute(title: String) = - Route.NewWorkout( - exercise = exercise.code, - title = title, - durationSeconds = durationSeconds, - distanceMeters = distanceMeters ?: 0.0, - calories = calories ?: 0, - avgHeartRate = avgHeartRate ?: 0, - maxHeartRate = maxHeartRate ?: 0, - steps = steps ?: 0, - elevationGainMeters = elevationGainMeters ?: 0.0, - startTime = startTimeEpochSeconds, - source = SourceTag.HEALTH_CONNECT, - ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt new file mode 100644 index 0000000000..4e6d0be653 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt @@ -0,0 +1,62 @@ +/* + * 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 android.text.format.DateUtils +import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag + +/** Builds the pre-filled composer route for a detected workout. Shared by the + * feed suggestion banner and the New Workout carousel so they never drift. */ +internal fun DetectedWorkout.toNewWorkoutRoute(title: String) = + Route.NewWorkout( + exercise = exercise.code, + title = title, + durationSeconds = durationSeconds, + distanceMeters = distanceMeters ?: 0.0, + calories = calories ?: 0, + avgHeartRate = avgHeartRate ?: 0, + maxHeartRate = maxHeartRate ?: 0, + steps = steps ?: 0, + elevationGainMeters = elevationGainMeters ?: 0.0, + startTime = startTimeEpochSeconds, + source = SourceTag.HEALTH_CONNECT, + ) + +internal fun formatWorkoutDuration(totalSeconds: Long): String { + val h = totalSeconds / 3600 + val m = (totalSeconds % 3600) / 60 + val s = totalSeconds % 60 + return if (h > 0) { + "%d:%02d:%02d".format(h, m, s) + } else { + "%d:%02d".format(m, s) + } +} + +internal fun workoutRelativeTime(epochSeconds: Long): String = + DateUtils + .getRelativeTimeSpanString( + epochSeconds * 1000L, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 765ff9c146..eb691a1ee7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -710,6 +710,7 @@ %1$s km %1$s bpm %1$s kcal + From Health Connect Running Walking Cycling