diff --git a/android/app/src/main/java/com/noop/ui/WorkoutStart.kt b/android/app/src/main/java/com/noop/ui/WorkoutStart.kt index 13e9197a5b..2fdb9ebe2d 100644 --- a/android/app/src/main/java/com/noop/ui/WorkoutStart.kt +++ b/android/app/src/main/java/com/noop/ui/WorkoutStart.kt @@ -11,6 +11,7 @@ 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 @@ -18,6 +19,7 @@ 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 @@ -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, @@ -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) } @@ -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), @@ -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) { diff --git a/android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt b/android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt index b7bd055699..5e554e6c14 100644 --- a/android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt @@ -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. @@ -209,7 +208,6 @@ fun WorkoutsScreen(vm: AppViewModel) { LaunchedEffect(Unit) { vm.loadWorkouts() - loaded = true } LaunchedEffect(allRows) { if (!didPickDefaultRange && allRows.isNotEmpty()) { @@ -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 @@ -269,7 +268,6 @@ fun WorkoutsScreen(vm: AppViewModel) { fellBack = fellBack, filterActive = filter.isActive, onSelect = { range = it }, - onAdd = { dialog = DialogTarget(null) }, ) } item { @@ -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.", + ) } /** @@ -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)) @@ -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, @@ -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, @@ -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) + } + } + } } }