Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 36 additions & 14 deletions android/app/src/main/java/com/noop/ui/WorkoutStart.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
Expand Down Expand Up @@ -159,6 +161,14 @@ private fun StartSportRow(sp: Sport, isSelected: Boolean, onPick: () -> Unit) {
.clickable(onClick = onPick)
.padding(vertical = 10.dp),
) {
// Per-sport glyph (shared sportIcon catalogue), so the picker reads by icon like the iOS
// workout selection screen and the Workouts list rows — not a bare text list.
Icon(
sportIcon(sp.name), contentDescription = null,
tint = if (isSelected) Palette.accent else Palette.textSecondary,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(12.dp))
Text(
sp.name, style = NoopType.body,
color = if (isSelected) Palette.accent else Palette.textPrimary,
Expand All @@ -173,11 +183,11 @@ private fun StartSportRow(sp: Sport, isSelected: Boolean, onPick: () -> Unit) {
/**
* Start-a-workout entry for the Workouts screen (#115) — mirrors the Live screen's control so a user
* can begin a session from either place. Shows a compact "running" banner while a workout is active
* (the rich live card stays on Live), the "Start workout" button when a strap is bonded, or nothing
* when there's no strap to stream from (matching Live, which only offers the start when bonded).
* (the rich live card stays on Live); otherwise an action row with Start (when a strap is bonded, since
* a live session needs the strap to stream) beside Add — or just Add when there's no strap.
*/
@Composable
fun WorkoutStartSection(vm: AppViewModel) {
fun WorkoutStartSection(vm: AppViewModel, onAdd: () -> Unit) {
val live by vm.live.collectAsStateWithLifecycle()
val activeWorkout by vm.activeWorkout.collectAsStateWithLifecycle()
var showSportPicker by remember { mutableStateOf(false) }
Expand All @@ -194,9 +204,12 @@ fun WorkoutStartSection(vm: AppViewModel) {
while (true) { nowMs = System.currentTimeMillis(); delay(1000) }
}
val elapsedS = ((nowMs - w.startMs) / 1000).coerceAtLeast(0)
NoopCard {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(uiString(R.string.l10n_workout_start_w_sport_name_uppercase_e59bc678, w.sport.name.uppercase()), style = NoopType.overline, color = Palette.statusCritical)
// Recording: the live banner, with Add kept visible below so a past workout can still be logged
// mid-session (it used to live in the range bar).
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
NoopCard {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(uiString(R.string.l10n_workout_start_w_sport_name_uppercase_e59bc678, w.sport.name.uppercase()), style = NoopType.overline, color = Palette.statusCritical)
Spacer(Modifier.width(10.dp))
Text(
String.format("%d:%02d", elapsedS / 60, elapsedS % 60),
Expand All @@ -217,17 +230,26 @@ fun WorkoutStartSection(vm: AppViewModel) {
containerColor = Palette.statusCritical, contentColor = Palette.surfaceBase,
),
) { Text(uiString(R.string.l10n_workout_start_end_a2bb9d34), style = NoopType.captionNumber) }
}
}
AddWorkoutButton(onAdd, Modifier.fillMaxWidth())
}
} else if (live.bonded) {
Button(
onClick = { showSportPicker = true },
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Palette.accent, contentColor = Palette.surfaceBase,
),
) { Text(uiString(R.string.l10n_workout_start_start_workout_d0f3f2cd), style = NoopType.captionNumber) }
// Start + Add as an equal-width action row (EXP-018 parity with the iOS workoutActionRow).
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { showSportPicker = true },
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Palette.accent, contentColor = Palette.surfaceBase,
),
) { Text(uiString(R.string.l10n_workout_start_start_workout_d0f3f2cd), style = NoopType.captionNumber) }
AddWorkoutButton(onAdd, Modifier.weight(1f))
}
} else {
// No strap to stream from: no live Start, but keep Add so a user with no imports can still log.
AddWorkoutButton(onAdd, Modifier.fillMaxWidth())
}

if (showSportPicker) {
Expand Down
73 changes: 37 additions & 36 deletions android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ fun WorkoutsScreen(vm: AppViewModel) {
val lastHistorySyncAt by vm.lastHistorySyncAt.collectAsStateWithLifecycle()
// Cached daily metrics — the Charge side of the post-log activity-cost note (#439).
val recentDays by vm.recentDays.collectAsStateWithLifecycle()
var loaded by remember { mutableStateOf(false) }
var range by remember { mutableStateOf(WorkoutRange.All) }
// Pick the default range ONCE on first non-empty load; later mutations must not fight a range the
// user chose. Mirrors macOS, which sets the default only in `.task` / first onAppear.
Expand Down Expand Up @@ -209,7 +208,6 @@ fun WorkoutsScreen(vm: AppViewModel) {

LaunchedEffect(Unit) {
vm.loadWorkouts()
loaded = true
}
LaunchedEffect(allRows) {
if (!didPickDefaultRange && allRows.isNotEmpty()) {
Expand Down Expand Up @@ -245,13 +243,14 @@ fun WorkoutsScreen(vm: AppViewModel) {
fullBleedBackground = showDayCycleBackground && skyBehindCards,
) {
// Start (or stop) a workout right here, not only on Live — mirrors the Live control (#115).
// Start + Add sit side-by-side as an action row when a strap is bonded (EXP-018 parity).
item {
WorkoutStartSection(vm)
WorkoutStartSection(vm, onAdd = { dialog = DialogTarget(null) })
}

if (allRows.isEmpty()) {
item {
EmptyWorkouts(loaded, onAdd = { dialog = DialogTarget(null) })
EmptyWorkouts()
}
} else {
// Resolve the effective range + windowed rows + per-sport groups once. #64: the pure
Expand All @@ -269,7 +268,6 @@ fun WorkoutsScreen(vm: AppViewModel) {
fellBack = fellBack,
filterActive = filter.isActive,
onSelect = { range = it },
onAdd = { dialog = DialogTarget(null) },
)
}
item {
Expand Down Expand Up @@ -365,15 +363,14 @@ private data class WorkoutRecoveryTrendPoint(
// MARK: - Empty / loading state

@Composable
private fun EmptyWorkouts(loaded: Boolean, onAdd: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
DataPendingNote(
title = uiString(R.string.l10n_workouts_screen_no_workouts_yet_85a92042),
body = "No workouts yet. They come from your WHOOP and Apple Health history. " +
"Import in Data Sources to bring them in, or add one you tracked elsewhere.",
)
if (loaded) AddWorkoutButton(onAdd)
}
private fun EmptyWorkouts() {
// Add lives in the action row above (WorkoutStartSection provides it in every idle state), so the
// empty state is just the note — no second Add button here.
DataPendingNote(
title = uiString(R.string.l10n_workouts_screen_no_workouts_yet_85a92042),
body = "No workouts yet. They come from your WHOOP and Apple Health history. " +
"Import in Data Sources to bring them in, or add one you tracked elsewhere.",
)
}

/**
Expand Down Expand Up @@ -408,13 +405,14 @@ private fun PostLogNoteBanner(text: String) {
/** The "Add workout" pill — opens the manual add dialog. Shown on both the populated screen
* (in the range bar) and the empty state, so a user with no imports can still log a session. */
@Composable
private fun AddWorkoutButton(onAdd: () -> Unit) {
internal fun AddWorkoutButton(onAdd: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = Modifier
modifier = modifier
.clip(RoundedCornerShape(50))
.background(Palette.accentMuted)
.clickable(onClick = onAdd)
.padding(horizontal = 14.dp, vertical = 8.dp),
.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Add, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(16.dp))
Expand All @@ -433,12 +431,10 @@ private fun RangeBar(
fellBack: Boolean,
filterActive: Boolean,
onSelect: (WorkoutRange) -> Unit,
onAdd: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Phone width can't fit the labelled Add button beside the 5-segment range pill without
// crushing/clipping one — stack them (button, then pill), matching the iPhone fix (#234/#339).
AddWorkoutButton(onAdd)
// Add moved up beside Start in WorkoutStartSection (glanceable action row), so the range pill
// now owns this row alone — no more Add-vs-5-segment-pill width fight (#234/#339).
SegmentedPillControl(
items = WorkoutRange.entries,
selection = range,
Expand Down Expand Up @@ -531,21 +527,10 @@ private fun FilterBar(
)
}
}
if (filter.isActive) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(50))
.clickable(onClick = onClear)
.padding(horizontal = 8.dp, vertical = 6.dp)
.semantics { contentDescription = uiString(R.string.l10n_workouts_screen_clear_filters_41222671) },
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Close, contentDescription = null, tint = Palette.textSecondary, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text(uiString(R.string.l10n_workouts_screen_clear_719ea396), style = NoopType.footnote, color = Palette.textSecondary)
}
}
}
// Search row: the field with the Clear-filters chip beside it (parity — iOS moved Clear onto the
// search row with a 44pt hit target; here a 48dp minimum).
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = filter.search,
onValueChange = onSearch,
Expand All @@ -560,8 +545,24 @@ private fun FilterBar(
placeholder = { Text(uiString(R.string.l10n_workouts_screen_search_sport_004b7928), style = NoopType.body, color = Palette.textTertiary) },
singleLine = true,
colors = workoutFieldColors(),
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f),
)
if (filter.isActive) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(50))
.clickable(onClick = onClear)
.heightIn(min = 48.dp)
.padding(horizontal = 8.dp, vertical = 6.dp)
.semantics { contentDescription = uiString(R.string.l10n_workouts_screen_clear_filters_41222671) },
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Close, contentDescription = null, tint = Palette.textSecondary, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text(uiString(R.string.l10n_workouts_screen_clear_719ea396), style = NoopType.footnote, color = Palette.textSecondary)
}
}
}
}
}

Expand Down