Workout check-in: missed state + rest-day compensation (KC-16)
- Notification actions on workout reminders/check-ins: "Yes, I completed it", "No, I didn't" (marks the day missed, then asks to compensate on a rest day), "Remind me in 1 hour" (snooze). - VM: missedDates + compensations (persisted/synced), per-date markWorkoutDone/Missed, workoutStatus, upcomingRestDays, scheduleCompensation, promptCompensation, checkPendingMissed. program(for:) returns a compensation program on its rest day. - CompensationSheet: pick a rest day or keep as missed; compensation days get a one-off "Missed workout recovery" reminder. - Long-press menus: workout row (Done / Not Done / Compensate) in Today + Calendar; exercise cards (Mark as Done / Not Done). All actions are per-date — one occurrence never affects another. - Workout row shows Pending / Done / Missed + "Compensation" label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,17 @@ struct WorkoutView: View {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
withAnimation { vm.setExerciseDone(sectionId: section.id, exerciseId: ex.id, done: true) }
|
||||
} label: {
|
||||
Label("Mark as Done", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button {
|
||||
withAnimation { vm.setExerciseDone(sectionId: section.id, exerciseId: ex.id, done: false) }
|
||||
} label: {
|
||||
Label("Mark as Not Done", systemImage: "circle")
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
withAnimation { vm.deleteExercise(sectionId: section.id, exerciseId: ex.id) }
|
||||
} label: {
|
||||
@@ -762,6 +773,88 @@ struct WorkoutHistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compensation Sheet ("missed → make it up on a rest day")
|
||||
struct CompensationSheet: View {
|
||||
let missed: WorkoutViewModel.MissedDay
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
private let dayFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f
|
||||
}()
|
||||
|
||||
private var restDays: [Date] { vm.upcomingRestDays() }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "arrow.uturn.forward.circle")
|
||||
.font(.system(size: 30, weight: .light))
|
||||
.foregroundColor(AppColors.accent)
|
||||
Text("Missed: \(missed.programName)")
|
||||
.font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Text("Do you want to compensate for this workout on a rest day?")
|
||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center).padding(.horizontal, 24)
|
||||
}
|
||||
.padding(.top, 24).padding(.bottom, 16)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
if restDays.isEmpty {
|
||||
Text("No free rest days in the next two weeks.")
|
||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 30)
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(restDays, id: \.self) { day in
|
||||
Button {
|
||||
if let pid = missed.programId {
|
||||
vm.scheduleCompensation(programId: pid, on: day)
|
||||
}
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "moon.zzz")
|
||||
.font(.system(size: 13)).foregroundColor(AppColors.accent)
|
||||
.frame(width: 28)
|
||||
Text(dayFmt.string(from: day))
|
||||
.font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Text("COMPENSATE")
|
||||
.font(AppFonts.mono(8.5, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
.background(AppColors.accentSoft).clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 18).padding(.vertical, 13)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if day != restDays.last { Divider().background(AppColors.border(cs)).padding(.leading, 46) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button { dismiss() } label: {
|
||||
Text("No, keep as missed")
|
||||
.font(AppFonts.sans(14, weight: .semibold))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.frame(maxWidth: .infinity).padding(13)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 20).padding(.bottom, 20).padding(.top, 8)
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rest Day Card
|
||||
struct RestDayCard: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
Reference in New Issue
Block a user