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
4 changes: 2 additions & 2 deletions Strand/Liquid/LiquidTodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,8 @@ struct LiquidTodayView: View {
// #105: the live "workout in progress" card, dropped in the liquid Home rewrite. Restored
// here as the SAME leaf the classic TodayView renders (and Android's WorkoutInProgressCard),
// pinned above the reorderable block so an active manual workout is immediately visible
// and opens the existing workout flow. Today also offers Start when no workout is active.
ActiveWorkoutIndicatorSection(showStart: selectedDayOffset == 0)
// and taps straight through to Live. Renders nothing when no workout is active.
ActiveWorkoutIndicatorSection()
// #today-layout (parity with Android): every Today section — the Charge/Effort/Rest hero
// and Start-session included — renders in the user's saved order. Reorder via the Arrange
// sheet (the header's up/down button; native drag rows); the order persists under the
Expand Down
18 changes: 9 additions & 9 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ struct ActiveWorkoutIndicatorModel: Equatable {
}
}

struct ActiveWorkoutIndicatorCard: View {
private struct ActiveWorkoutIndicatorCard: View {
let model: ActiveWorkoutIndicatorModel
let onReturn: () -> Void

Expand Down Expand Up @@ -165,23 +165,19 @@ struct ActiveWorkoutIndicatorCard: View {

/// Leaf-isolated so an in-progress workout's ~per-sample `AppModel` churn (the elapsed clock tick + the
/// rewritten `activeWorkout`) re-renders ONLY this card, never the whole Today dashboard, the same
/// leaf-isolation pattern the file documents for the live status/sync rows. With `showStart`, today's
/// idle state offers the shared workout picker; past days only show an active-workout indicator.
/// leaf-isolation pattern the file documents for the live status/sync rows. Renders nothing when no workout
/// is active, so the card auto-appears/clears purely off `AppModel.activeWorkout`.
///
/// Non-private so the liquid Home (`LiquidTodayView`) renders the SAME leaf — the liquid rewrite dropped this
/// indicator (#105), and sharing one implementation keeps the two Today screens (and Android's
/// `WorkoutInProgressCard`) from drifting. It carries its own `app`/`router` environment objects, so a caller
/// only needs to place `ActiveWorkoutIndicatorSection()` in its body.
struct ActiveWorkoutIndicatorSection: View {
var showStart = false
@EnvironmentObject var app: AppModel
@EnvironmentObject var router: NavRouter

var body: some View {
if showStart {
// Keep this host mounted when a workout starts so its picker and live view survive the update.
WorkoutStartControl(showsActiveIndicator: true)
} else if let model = ActiveWorkoutIndicatorModel.make(from: app.activeWorkout) {
if let model = ActiveWorkoutIndicatorModel.make(from: app.activeWorkout) {
ActiveWorkoutIndicatorCard(model: model) {
StrandHaptic.selection.play()
router.openActiveWorkout()
Expand Down Expand Up @@ -1491,7 +1487,11 @@ struct TodayView: View {
// A "workout in progress" indicator whenever a manual workout is active. A tap routes to Live
// and opens the in-exercise screen. Its own leaf owns the AppModel observation + per-second
// clock, so the live tick never re-renders TodayView.body.
ActiveWorkoutIndicatorSection(showStart: selectedDayOffset == 0)
//
// No Start here, the Android twin carries the reasoning (TodayScreen.kt, the same block):
// #2467 wanted Today's Start to arrive WITH the recording-plus-coaching merge, and only the
// button was built, so Today held both of the entry points the report was about.
ActiveWorkoutIndicatorSection()
// The "still building" and "new here?" prompts are about getting today's scores going,
// so they stay anchored to today rather than reappearing on every navigated past day.
if selectedDayOffset == 0 && repo.today?.recovery == nil {
Expand Down
28 changes: 9 additions & 19 deletions Strand/Screens/WorkoutStartControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,21 @@ import StrandDesign
/// re-renders only this small leaf. Owns its own sheet-presentation state so nothing about it needs to
/// live on the parent either.
struct WorkoutStartControl: View {
var showsActiveIndicator = false
@EnvironmentObject var model: AppModel
@State private var showLiveWorkout = false
@State private var showStartSport = false

var body: some View {
Group {
if showsActiveIndicator, let active = ActiveWorkoutIndicatorModel.make(from: model.activeWorkout) {
ActiveWorkoutIndicatorCard(model: active) {
StrandHaptic.selection.play()
showLiveWorkout = true
}
} else {
NoopButton(model.activeWorkout == nil ? "Start workout" : "View active workout",
systemImage: model.activeWorkout == nil ? "figure.run" : "timer",
kind: .primary,
fullWidth: true) {
// No active session → pick a named sport first (#519), then the sheet's onStart begins it
// and opens the in-exercise view. Already active → jump straight back into the live view.
if model.activeWorkout == nil { showStartSport = true }
else { showLiveWorkout = true }
}
.accessibilityLabel(model.activeWorkout == nil ? "Start a workout" : "View the active workout")
}
NoopButton(model.activeWorkout == nil ? "Start workout" : "View active workout",
systemImage: model.activeWorkout == nil ? "figure.run" : "timer",
kind: .primary,
fullWidth: true) {
// No active session → pick a named sport first (#519), then the sheet's onStart begins it
// and opens the in-exercise view. Already active → jump straight back into the live view.
if model.activeWorkout == nil { showStartSport = true }
else { showLiveWorkout = true }
}
.accessibilityLabel(model.activeWorkout == nil ? "Start a workout" : "View the active workout")
// #459: the in-exercise view, presented when Start Workout is tapped here (same screen LiveView
// shows). activeWorkout is global on AppModel, so ending it from either surface stays in sync.
.sheet(isPresented: $showLiveWorkout) {
Expand Down
27 changes: 16 additions & 11 deletions android/app/src/main/java/com/noop/ui/TodayScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1501,17 +1501,22 @@ fun TodayScreen(
}
}

// Today's shared workout host offers Start when idle and the recording controls when active.
// Past days retain the active-workout shortcut. Per-second clocks remain inside their leaves.
if (selectedDayOffset == 0) {
// Keep the existing picker/live-view host mounted across idle → recording transitions.
// Manual entry stays on Workouts; Today offers the same live recording controls.
item { WorkoutStartSection(viewModel) }
} else {
activeWorkout?.let { w ->
item {
WorkoutInProgressCard(workout = w, onReturn = onOpenActiveWorkout)
}
// A "workout in progress" indicator whenever a manual workout is active (iOS parity: the Today
// ActiveWorkoutIndicator). A tap routes to Live and re-opens the in-exercise overlay. Gated purely on
// `activeWorkout`, so it auto-appears/clears with no extra lifecycle wiring. Its per-second clock
// ticks inside the card's own LaunchedEffect, never recomposing the Today body.
//
// STARTING a workout is deliberately not offered here, and that is the whole of #2467's first part
// being held back until its other four exist. #2467 asked for Today's Start to come WITH the merge
// of recording and heart-rate coaching into one session: one Start/Pause/End pair, one screen, one
// saved summary. Only the button was built, so Today ended up carrying both of the entry points the
// report opened about, "Start session" for coaching and "Start workout" for recording, which is more
// of the confusion it described rather than less. It also drew as a full-width primary slab above the
// scores, because the Start-beside-Add row it belongs to collapses to a single weighted child when
// Today passes no Add. Starting stays on Workouts, the centre FAB and Live meanwhile.
activeWorkout?.let { w ->
item {
WorkoutInProgressCard(workout = w, onReturn = onOpenActiveWorkout)
}
}

Expand Down
14 changes: 7 additions & 7 deletions android/app/src/main/java/com/noop/ui/WorkoutStart.kt
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,13 @@ private fun StartSportRow(sp: Sport, isSelected: Boolean, onPick: () -> Unit) {
}

/**
* Shared workout entry for Workouts and Today — mirrors the Live screen's control so a user
* can begin a session from each place. Shows a compact "running" banner while a workout is active
* 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); otherwise an action row with Start (when a strap is bonded, since
* a live session needs the strap to stream). Workouts supplies Add; Today omits manual entry.
* a live session needs the strap to stream) beside Add — or just Add when there's no strap.
*/
@Composable
fun WorkoutStartSection(vm: AppViewModel, onAdd: (() -> Unit)? = null) {
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 Down Expand Up @@ -261,7 +261,7 @@ fun WorkoutStartSection(vm: AppViewModel, onAdd: (() -> Unit)? = null) {
}
}
}
if (onAdd != null) AddWorkoutButton(onAdd, Modifier.fillMaxWidth())
AddWorkoutButton(onAdd, Modifier.fillMaxWidth())
}
} else if (live.bonded) {
// Start + Add as an equal-width action row (EXP-018 parity with the iOS workoutActionRow).
Expand All @@ -278,9 +278,9 @@ fun WorkoutStartSection(vm: AppViewModel, onAdd: (() -> Unit)? = null) {
onClick = { showSportPicker = true },
modifier = Modifier.weight(1f),
)
if (onAdd != null) AddWorkoutButton(onAdd, Modifier.weight(1f))
AddWorkoutButton(onAdd, Modifier.weight(1f))
}
} else if (onAdd != null) {
} else {
// No strap to stream from: no live Start, but keep Add so a user with no imports can still log.
// PRIMARY here, unlike the pair above. There is no Start to be secondary to, and this is the
// only action the screen offers, so the secondary treatment would leave a wearer with no strap
Expand Down
Loading