End the watch session on Discard, plus start-flow UX tweaks
Watch-side follow-through for the End Workout flow: - The phone now pushes an authoritative set (in-progress, not-started, and completed within 24h) instead of the 25 most-recent workouts, and the watch prunes any workout absent from it. So a Discard/Delete (or a completed run aging out) drops off the watch, empties its active list, and ends the HKWorkoutSession — fixing the persistent wrist-raise re-foregrounding. The watch never originates a workout, so pruning can't lose local data; the 24h grace keeps a just-finished run on screen. The gate pops if the run you're viewing is pruned. UX tweaks: - The in-workout ⋯ is now a pull-down Menu (Add Exercise / End Workout) rather than an action sheet. - Starting a split while another workout is still active now prompts to end the current one(s) — keeping their progress — or run in parallel. Wired into both start paths (the split picker and "Start This Split"), via a shared WorkoutDocument.endKeepingProgress() helper. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
**June 2026**
|
**June 2026**
|
||||||
|
|
||||||
|
Starting a new workout while another is still going now asks whether to end the current one first or run both in parallel.
|
||||||
|
|
||||||
|
Discarding or deleting an in-progress workout on iPhone now takes your Apple Watch out of the workout too, so the watch no longer keeps waking to the workout app when you raise your wrist.
|
||||||
|
|
||||||
You can now end a workout before finishing every exercise: open the ⋯ menu and choose End Workout, then Save to keep what you did — any exercises you didn't get to are marked skipped — or Discard to remove the workout.
|
You can now end a workout before finishing every exercise: open the ⋯ menu and choose End Workout, then Save to keep what you did — any exercises you didn't get to are marked skipped — or Discard to remove the workout.
|
||||||
|
|
||||||
Right after you turn on iCloud Drive, Workouts now waits for it to finish coming online instead of giving up and showing the "turn on iCloud Drive" screen too soon. The connecting screen also keeps you posted as it works, letting you know the first connect can take a moment.
|
Right after you turn on iCloud Drive, Workouts now waits for it to finish coming online instead of giving up and showing the "turn on iCloud Drive" screen too soon. The connecting screen also keeps you posted as it works, letting you know the first connect can take a moment.
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ extension WorkoutDocument {
|
|||||||
end = nil
|
end = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End the workout now, keeping progress: mark every not-completed log as skipped, then
|
||||||
|
/// recompute so it resolves to `.completed` (with `end` stamped). This is the
|
||||||
|
/// "End Workout → Save" operation, shared by the in-workout menu and the
|
||||||
|
/// start-a-new-split prompt.
|
||||||
|
mutating func endKeepingProgress() {
|
||||||
|
for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed {
|
||||||
|
logs[i].status = WorkoutStatus.skipped.rawValue
|
||||||
|
logs[i].completed = false
|
||||||
|
}
|
||||||
|
recomputeStatusFromLogs()
|
||||||
|
updatedAt = Date()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
|
struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
|||||||
@@ -185,15 +185,22 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
|
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
|
||||||
liveSplitIDs.insert(s.id)
|
liveSplitIDs.insert(s.id)
|
||||||
}
|
}
|
||||||
|
var liveWorkoutIDs = Set<String>()
|
||||||
for w in workouts {
|
for w in workouts {
|
||||||
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
|
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
|
||||||
|
liveWorkoutIDs.insert(w.id)
|
||||||
}
|
}
|
||||||
// Splits are sent in full → prune any the phone no longer has. Workouts are
|
// Both are authoritative sets → prune anything the phone no longer sends. For
|
||||||
// sent as a recent window, so they're upserted but never pruned (avoids a
|
// workouts that set is every active run plus recently-completed ones (~24h), so a
|
||||||
// race deleting a workout just created on the watch).
|
// run that was discarded/deleted on the phone (or aged out of the window) drops out
|
||||||
|
// of the push and is pruned here — which empties the active list and ends the
|
||||||
|
// session. The watch never originates a workout, so pruning can't lose local data.
|
||||||
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
|
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
|
||||||
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
|
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
|
||||||
}
|
}
|
||||||
|
if let allWorkouts = try? context.fetch(FetchDescriptor<Workout>()) {
|
||||||
|
for w in allWorkouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
|
||||||
|
}
|
||||||
try? context.save()
|
try? context.save()
|
||||||
lastSyncDate = Date()
|
lastSyncDate = Date()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,11 @@ struct ActiveWorkoutGateView: View {
|
|||||||
if noActiveWorkouts { sessionManager.end() }
|
if noActiveWorkouts { sessionManager.end() }
|
||||||
}
|
}
|
||||||
// The phone just entered (or left) an editor — if we're inside the now-locked run,
|
// The phone just entered (or left) an editor — if we're inside the now-locked run,
|
||||||
// pop back to the gate so re-entry rebuilds a fresh working copy.
|
// pop back to the gate so re-entry rebuilds a fresh working copy. Also pop if the run
|
||||||
.onChange(of: bridge.editingWorkoutID) { _, _ in popIfNavigatedRunLocked() }
|
// we're inside was pruned (discarded/deleted on the phone, or aged out of the push).
|
||||||
.onChange(of: bridge.editingSplitID) { _, _ in popIfNavigatedRunLocked() }
|
.onChange(of: bridge.editingWorkoutID) { _, _ in popIfNavigatedRunUnavailable() }
|
||||||
|
.onChange(of: bridge.editingSplitID) { _, _ in popIfNavigatedRunUnavailable() }
|
||||||
|
.onChange(of: workouts.map(\.id)) { _, _ in popIfNavigatedRunUnavailable() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -94,10 +96,15 @@ struct ActiveWorkoutGateView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If the run we're currently navigated into has become locked, pop to the gate.
|
/// If the run we're currently navigated into is no longer available — pruned from the
|
||||||
private func popIfNavigatedRunLocked() {
|
/// cache (discarded/deleted on the phone, or aged out of the pushed set), or locked
|
||||||
guard let route = path.last,
|
/// because the phone took over editing it — pop back to the gate.
|
||||||
let workout = workouts.first(where: { $0.id == route.workoutID }) else { return }
|
private func popIfNavigatedRunUnavailable() {
|
||||||
|
guard let route = path.last else { return }
|
||||||
|
guard let workout = workouts.first(where: { $0.id == route.workoutID }) else {
|
||||||
|
path.removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
if isLockedForEditing(workout) { path.removeAll() }
|
if isLockedForEditing(workout) { path.removeAll() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,9 +60,30 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
session.isWatchAppInstalled else { return }
|
session.isWatchAppInstalled else { return }
|
||||||
|
|
||||||
let splits = (try? context.fetch(FetchDescriptor<Split>(sortBy: [SortDescriptor(\.order)]))) ?? []
|
let splits = (try? context.fetch(FetchDescriptor<Split>(sortBy: [SortDescriptor(\.order)]))) ?? []
|
||||||
var wDesc = FetchDescriptor<Workout>(sortBy: [SortDescriptor(\.start, order: .reverse)])
|
|
||||||
wDesc.fetchLimit = 25
|
// The watch only needs what it can act on: every active run (in-progress /
|
||||||
let workouts = (try? context.fetch(wDesc)) ?? []
|
// not-started) plus recently-completed ones, kept ~24h so a run that just
|
||||||
|
// finished still renders before the watch prunes it. The watch treats this as an
|
||||||
|
// authoritative set and prunes anything absent — that's what ends its session on a
|
||||||
|
// Discard/Delete. Active runs are sent in full (no cap): there are only ever a
|
||||||
|
// handful, so "absent" unambiguously means "no longer active".
|
||||||
|
let inProgressRaw = WorkoutStatus.inProgress.rawValue
|
||||||
|
let notStartedRaw = WorkoutStatus.notStarted.rawValue
|
||||||
|
let completedRaw = WorkoutStatus.completed.rawValue
|
||||||
|
let cutoff = Date(timeIntervalSinceNow: -86_400)
|
||||||
|
|
||||||
|
let activeDesc = FetchDescriptor<Workout>(
|
||||||
|
predicate: #Predicate<Workout> { $0.statusRaw == inProgressRaw || $0.statusRaw == notStartedRaw },
|
||||||
|
sortBy: [SortDescriptor(\.start, order: .reverse)]
|
||||||
|
)
|
||||||
|
var completedDesc = FetchDescriptor<Workout>(
|
||||||
|
predicate: #Predicate<Workout> { $0.statusRaw == completedRaw },
|
||||||
|
sortBy: [SortDescriptor(\.start, order: .reverse)]
|
||||||
|
)
|
||||||
|
completedDesc.fetchLimit = 25
|
||||||
|
let active = (try? context.fetch(activeDesc)) ?? []
|
||||||
|
let recentCompleted = ((try? context.fetch(completedDesc)) ?? []).filter { ($0.end ?? $0.start) > cutoff }
|
||||||
|
let workouts = active + recentCompleted
|
||||||
|
|
||||||
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
|
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
|
||||||
let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5
|
let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ struct ExerciseListView: View {
|
|||||||
@State private var pendingWorkoutID: String? = nil
|
@State private var pendingWorkoutID: String? = nil
|
||||||
@State private var resolvedWorkout: Workout? = nil
|
@State private var resolvedWorkout: Workout? = nil
|
||||||
|
|
||||||
|
@Query(sort: \Workout.start, order: .reverse)
|
||||||
|
private var workouts: [Workout]
|
||||||
|
|
||||||
|
@State private var showingActivePrompt = false
|
||||||
|
|
||||||
|
private var activeWorkouts: [Workout] {
|
||||||
|
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
let sortedExercises = split.exercisesArray
|
let sortedExercises = split.exercisesArray
|
||||||
@@ -67,7 +76,7 @@ struct ExerciseListView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button("Start This Split") {
|
Button("Start This Split") {
|
||||||
startWorkout()
|
confirmAndStart()
|
||||||
}
|
}
|
||||||
.disabled(split.exercisesArray.isEmpty)
|
.disabled(split.exercisesArray.isEmpty)
|
||||||
}
|
}
|
||||||
@@ -108,10 +117,53 @@ struct ExerciseListView: View {
|
|||||||
} message: { item in
|
} message: { item in
|
||||||
Text("Remove \"\(item.name)\" from this split?")
|
Text("Remove \"\(item.name)\" from this split?")
|
||||||
}
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
activePromptTitle,
|
||||||
|
isPresented: $showingActivePrompt,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("End Current & Start New") { endActiveThenStart() }
|
||||||
|
Button("Start in Parallel") { start() }
|
||||||
|
Button("Cancel", role: .cancel) { showingActivePrompt = false }
|
||||||
|
} message: {
|
||||||
|
Text(activePromptMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var activePromptTitle: String {
|
||||||
|
activeWorkouts.count == 1 ? "Workout in Progress" : "\(activeWorkouts.count) Workouts in Progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activePromptMessage: String {
|
||||||
|
let n = activeWorkouts.count
|
||||||
|
let those = n == 1 ? "it" : "them"
|
||||||
|
return "You already have \(n == 1 ? "a workout" : "\(n) workouts") going. End \(those) first, or run this one alongside."
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt before starting if other workouts are still going; otherwise start straight away.
|
||||||
|
private func confirmAndStart() {
|
||||||
|
if activeWorkouts.isEmpty {
|
||||||
|
start()
|
||||||
|
} else {
|
||||||
|
showingActivePrompt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End every in-flight workout (keeping its progress), then start this split.
|
||||||
|
private func endActiveThenStart() {
|
||||||
|
let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) }
|
||||||
|
showingActivePrompt = false
|
||||||
|
Task {
|
||||||
|
for var doc in toEnd {
|
||||||
|
doc.endKeepingProgress()
|
||||||
|
await sync.save(workout: doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
private func pollForWorkout(id: String) {
|
private func pollForWorkout(id: String) {
|
||||||
Task {
|
Task {
|
||||||
// Give the file→observer→cache loop a moment to complete (typically < 1 s).
|
// Give the file→observer→cache loop a moment to complete (typically < 1 s).
|
||||||
@@ -141,8 +193,8 @@ struct ExerciseListView: View {
|
|||||||
Task { await sync.save(split: doc) }
|
Task { await sync.save(split: doc) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startWorkout() {
|
private func start() {
|
||||||
let start = Date()
|
let startDate = Date()
|
||||||
let logs = split.exercisesArray.enumerated().map { i, ex in
|
let logs = split.exercisesArray.enumerated().map { i, ex in
|
||||||
WorkoutLogDocument(
|
WorkoutLogDocument(
|
||||||
id: ULID.make(), exerciseName: ex.name, order: i,
|
id: ULID.make(), exerciseName: ex.name, order: i,
|
||||||
@@ -150,7 +202,7 @@ struct ExerciseListView: View {
|
|||||||
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
|
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
|
||||||
currentStateIndex: 0, completed: false,
|
currentStateIndex: 0, completed: false,
|
||||||
status: WorkoutStatus.notStarted.rawValue,
|
status: WorkoutStatus.notStarted.rawValue,
|
||||||
notes: nil, date: start
|
notes: nil, date: startDate
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
let doc = WorkoutDocument(
|
let doc = WorkoutDocument(
|
||||||
@@ -158,11 +210,11 @@ struct ExerciseListView: View {
|
|||||||
id: ULID.make(),
|
id: ULID.make(),
|
||||||
splitID: split.id,
|
splitID: split.id,
|
||||||
splitName: split.name,
|
splitName: split.name,
|
||||||
start: start,
|
start: startDate,
|
||||||
end: nil,
|
end: nil,
|
||||||
status: WorkoutStatus.notStarted.rawValue,
|
status: WorkoutStatus.notStarted.rawValue,
|
||||||
createdAt: start,
|
createdAt: startDate,
|
||||||
updatedAt: start,
|
updatedAt: startDate,
|
||||||
logs: logs
|
logs: logs
|
||||||
)
|
)
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ struct WorkoutLogListView: View {
|
|||||||
@State private var doc: WorkoutDocument
|
@State private var doc: WorkoutDocument
|
||||||
|
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
@State private var showingActions = false
|
|
||||||
@State private var showingEndOptions = false
|
@State private var showingEndOptions = false
|
||||||
@State private var logToDelete: WorkoutLogDocument?
|
@State private var logToDelete: WorkoutLogDocument?
|
||||||
@State private var addedLog: LogRoute?
|
@State private var addedLog: LogRoute?
|
||||||
@@ -132,8 +131,19 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
showingActions = true
|
showingAddSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Add Exercise", systemImage: "plus")
|
||||||
|
}
|
||||||
|
if !sortedLogs.isEmpty {
|
||||||
|
Button {
|
||||||
|
showingEndOptions = true
|
||||||
|
} label: {
|
||||||
|
Label("End Workout", systemImage: "flag.checkered")
|
||||||
|
}
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis.circle")
|
Image(systemName: "ellipsis.circle")
|
||||||
}
|
}
|
||||||
@@ -164,17 +174,6 @@ struct WorkoutLogListView: View {
|
|||||||
logToDelete = nil
|
logToDelete = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
|
||||||
"Workout",
|
|
||||||
isPresented: $showingActions,
|
|
||||||
titleVisibility: .hidden
|
|
||||||
) {
|
|
||||||
Button("Add Exercise") { showingAddSheet = true }
|
|
||||||
if !sortedLogs.isEmpty {
|
|
||||||
Button("End Workout") { showingEndOptions = true }
|
|
||||||
}
|
|
||||||
Button("Cancel", role: .cancel) {}
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"End Workout?",
|
"End Workout?",
|
||||||
isPresented: $showingEndOptions,
|
isPresented: $showingEndOptions,
|
||||||
@@ -313,11 +312,9 @@ struct WorkoutLogListView: View {
|
|||||||
/// the recompute resolves the workout to `.completed` (stamping `end`). The status flip
|
/// the recompute resolves the workout to `.completed` (stamping `end`). The status flip
|
||||||
/// is what drops the run off the watch's active list and ends its HealthKit session.
|
/// is what drops the run off the watch's active list and ends its HealthKit session.
|
||||||
private func endWorkout() {
|
private func endWorkout() {
|
||||||
for i in doc.logs.indices where workoutStatus(doc.logs[i]) != .completed {
|
doc.endKeepingProgress()
|
||||||
doc.logs[i].status = WorkoutStatus.skipped.rawValue
|
let snapshot = doc
|
||||||
doc.logs[i].completed = false
|
Task { await sync.save(workout: snapshot) }
|
||||||
}
|
|
||||||
save()
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,12 +114,23 @@ struct SplitPickerSheet: View {
|
|||||||
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
|
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
|
||||||
private var splits: [Split]
|
private var splits: [Split]
|
||||||
|
|
||||||
|
@Query(sort: \Workout.start, order: .reverse)
|
||||||
|
private var workouts: [Workout]
|
||||||
|
|
||||||
|
/// Set when the user picks a split while other workouts are still going — drives the
|
||||||
|
/// "end the current one(s) or run in parallel?" prompt.
|
||||||
|
@State private var splitAwaitingConfirmation: Split?
|
||||||
|
|
||||||
|
private var activeWorkouts: [Workout] {
|
||||||
|
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
ForEach(splits) { split in
|
ForEach(splits) { split in
|
||||||
Button {
|
Button {
|
||||||
startWorkout(with: split)
|
confirmAndStart(with: split)
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: split.systemImage)
|
Image(systemName: split.systemImage)
|
||||||
@@ -141,11 +152,58 @@ struct SplitPickerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
activePromptTitle,
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { splitAwaitingConfirmation != nil },
|
||||||
|
set: { if !$0 { splitAwaitingConfirmation = nil } }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: splitAwaitingConfirmation
|
||||||
|
) { split in
|
||||||
|
Button("End Current & Start New") { endActiveThenStart(with: split) }
|
||||||
|
Button("Start in Parallel") { start(with: split) }
|
||||||
|
Button("Cancel", role: .cancel) { splitAwaitingConfirmation = nil }
|
||||||
|
} message: { _ in
|
||||||
|
Text(activePromptMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startWorkout(with split: Split) {
|
private var activePromptTitle: String {
|
||||||
let start = Date()
|
activeWorkouts.count == 1 ? "Workout in Progress" : "\(activeWorkouts.count) Workouts in Progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activePromptMessage: String {
|
||||||
|
let n = activeWorkouts.count
|
||||||
|
let those = n == 1 ? "it" : "them"
|
||||||
|
return "You already have \(n == 1 ? "a workout" : "\(n) workouts") going. End \(those) first, or run this one alongside."
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt before starting if other workouts are still going; otherwise start straight away.
|
||||||
|
private func confirmAndStart(with split: Split) {
|
||||||
|
if activeWorkouts.isEmpty {
|
||||||
|
start(with: split)
|
||||||
|
} else {
|
||||||
|
splitAwaitingConfirmation = split
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End every in-flight workout (keeping its progress), then start the picked split.
|
||||||
|
private func endActiveThenStart(with split: Split) {
|
||||||
|
let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) }
|
||||||
|
splitAwaitingConfirmation = nil
|
||||||
|
Task {
|
||||||
|
for var doc in toEnd {
|
||||||
|
doc.endKeepingProgress()
|
||||||
|
await sync.save(workout: doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start(with: split)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start(with split: Split) {
|
||||||
|
let startDate = Date()
|
||||||
let logs = split.exercisesArray.enumerated().map { index, exercise in
|
let logs = split.exercisesArray.enumerated().map { index, exercise in
|
||||||
WorkoutLogDocument(
|
WorkoutLogDocument(
|
||||||
id: ULID.make(),
|
id: ULID.make(),
|
||||||
@@ -160,7 +218,7 @@ struct SplitPickerSheet: View {
|
|||||||
completed: false,
|
completed: false,
|
||||||
status: WorkoutStatus.notStarted.rawValue,
|
status: WorkoutStatus.notStarted.rawValue,
|
||||||
notes: nil,
|
notes: nil,
|
||||||
date: start
|
date: startDate
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,11 +228,11 @@ struct SplitPickerSheet: View {
|
|||||||
id: ULID.make(),
|
id: ULID.make(),
|
||||||
splitID: split.id,
|
splitID: split.id,
|
||||||
splitName: split.name,
|
splitName: split.name,
|
||||||
start: start,
|
start: startDate,
|
||||||
end: nil,
|
end: nil,
|
||||||
status: WorkoutStatus.notStarted.rawValue,
|
status: WorkoutStatus.notStarted.rawValue,
|
||||||
createdAt: start,
|
createdAt: startDate,
|
||||||
updatedAt: start,
|
updatedAt: startDate,
|
||||||
logs: logs
|
logs: logs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user