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:
kutesir
2026-06-10 23:06:58 +03:00
parent f252fc7707
commit 443fe8504f
7 changed files with 367 additions and 7 deletions

View File

@@ -94,6 +94,7 @@ struct ContentView: View {
.onAppear {
if !hasOnboarded { showOnboarding = true }
workoutVM.resetSetsForNewDayIfNeeded()
workoutVM.checkPendingMissed()
CloudSyncManager.shared.restoreSettings()
CloudSyncManager.shared.backupSettings()
HealthKitManager.shared.bootstrap()
@@ -111,6 +112,7 @@ struct ContentView: View {
consumeWidgetAddTask()
workoutVM.resetSetsForNewDayIfNeeded()
workoutVM.checkPendingHealthConfirm()
workoutVM.checkPendingMissed()
CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
Task {
@@ -123,6 +125,12 @@ struct ContentView: View {
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
WidgetCenter.shared.reloadAllTimelines()
}
.sheet(item: $workoutVM.askCompensate) { missed in
CompensationSheet(missed: missed)
.environmentObject(workoutVM)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.onChange(of: workoutVM.doneSets) { _ in
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}

View File

@@ -482,3 +482,44 @@ every widget to one look: dark slate background + brand orange, matching the app
Files: `WidgetViews.swift`, `EventCountdownWidget.swift`, `MyTasksWidget.swift`,
`KisaniCalWidgets.swift`.
---
## KC-16 — Workout check-in, missed state, and rest-day compensation
**Status:** In Progress (implemented & build-verified; notification actions need a real device)
**Reported by:** User
**Area:** Workout / Notifications
### Description
Workout reminders should ask "Have you done this workout?" with Yes / No / Snooze.
Saying No marks that day missed and offers to compensate on a rest day. Long-press
on workout/exercise cards gives quick Done / Not Done / Compensate actions. Each
scheduled day keeps its own completion state.
### Fix
- **VM:** `missedDates` + `compensations` (persisted + iCloud), `workoutStatus(on:)`
(pending/done/missed), `markWorkoutDone/markWorkoutMissed(on:)` (per-date only),
`upcomingRestDays`, `scheduleCompensation`, `promptCompensation`,
`checkPendingMissed`. `program(for:)` honors compensations; a compensation day
is not a rest day.
- **Notifications:** WORKOUT_CONFIRM category now has "Yes, I completed it ✓",
"No, I didn't" (→ marks missed + asks compensation on next open), and "Remind me
in 1 hour" (snooze re-delivers). Daily reminder + check-in both use it.
Compensation days get their own one-off reminder ("Missed workout recovery").
- **UI:** `CompensationSheet` (rest-day picker, "No, keep as missed"); long-press
menus on the calendar/Today workout row (Done / Not Done / Compensate) and on
exercise cards (Mark as Done / Not Done); workout row subtitle shows
Pending / Done / Missed and a "Compensation ·" prefix (missed shows accent color).
- Daily per-date rules unchanged (KC-1/KC-10): completing Tuesday never completes
Saturday.
Files: `ExerciseModels.swift`, `NotificationManager.swift`, `ContentView.swift`,
`WorkoutView.swift`, `CalendarView.swift`, `TodayView.swift`.
### How to test (device)
1. Enable workout reminder/check-in in Settings; when it fires, use the actions.
2. "No, I didn't" → open app → compensation sheet appears → pick a rest day →
that day now shows the program labeled Compensation (and gets a reminder).
3. Long-press the workout card in Today/Calendar → Done / Not Done / Compensate.
4. Long-press an exercise card in Workout → Mark as Done / Not Done.

View File

@@ -16,6 +16,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
private static let actionMarkId = "MARK_COMPLETE"
private static let confirmCatId = "WORKOUT_CONFIRM"
private static let logWorkoutActId = "LOG_WORKOUT"
private static let missedWorkoutActId = "MISSED_WORKOUT"
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
override private init() {
super.init()
@@ -30,8 +32,13 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
intentIdentifiers: [], options: .customDismissAction)
let logAction = UNNotificationAction(identifier: Self.logWorkoutActId,
title: "Yes, log it ✓", options: [])
let confirmCat = UNNotificationCategory(identifier: Self.confirmCatId, actions: [logAction],
title: "Yes, I completed it ✓", options: [])
let missedAction = UNNotificationAction(identifier: Self.missedWorkoutActId,
title: "No, I didn't", options: [])
let snoozeAction = UNNotificationAction(identifier: Self.snoozeWorkoutActId,
title: "Remind me in 1 hour", options: [])
let confirmCat = UNNotificationCategory(identifier: Self.confirmCatId,
actions: [logAction, missedAction, snoozeAction],
intentIdentifiers: [], options: .customDismissAction)
center.setNotificationCategories([taskCat, confirmCat])
@@ -80,6 +87,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
let recorded = workoutVM.isTodayRecorded
let tasks = taskVM.tasks
let calEvents = CalendarStore.shared.upcomingEventNotifs(days: 7)
let compensations = workoutVM.compensations
center.getNotificationSettings { [weak self] settings in
guard let self else { return }
@@ -88,12 +96,48 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
guard ok else { return }
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded)
self.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
self.scheduleCompensations(compensations, programs: programs)
self.scheduleTasks(snapshot: tasks)
self.scheduleCalendarEvents(snapshot: calEvents)
self.updateBadge(snapshot: tasks)
}
}
// MARK: - Compensation workout reminders (one-off, on chosen rest days)
private func scheduleCompensations(_ comps: [String: String], programs: [WorkoutProgram]) {
center.getPendingNotificationRequests { [weak self] pending in
guard let self else { return }
let old = pending.filter { $0.identifier.hasPrefix("kisani.workout.comp.") }.map { $0.identifier }
self.center.removePendingNotificationRequests(withIdentifiers: old)
let ud = UserDefaults.kisani
let rHour = ud.object(forKey: "workoutHour") as? Int ?? 18
let rMinute = ud.object(forKey: "workoutMinute") as? Int ?? 0
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
let cal = Calendar.current
for (dateStr, pidStr) in comps {
guard let day = fmt.date(from: dateStr), day > Date().addingTimeInterval(-86400),
let pid = UUID(uuidString: pidStr),
let program = programs.first(where: { $0.id == pid }) else { continue }
var comps = cal.dateComponents([.year, .month, .day], from: day)
comps.hour = rHour; comps.minute = rMinute
let content = UNMutableNotificationContent()
content.title = "\(program.name) — Compensation"
content.body = "Missed workout recovery — time to make it up!"
content.sound = .default
content.categoryIdentifier = Self.confirmCatId
content.userInfo = ["deeplink": "workout"]
self.center.add(UNNotificationRequest(
identifier: "kisani.workout.comp.\(dateStr)",
content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
))
}
}
}
// MARK: - Calendar event notifications
/// Notifies for visible calendar events: at start time ("on time") plus each
@@ -191,6 +235,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
content.title = program.name
content.body = "Time to work out — let's go!"
content.sound = .default
content.categoryIdentifier = Self.confirmCatId // Yes / No / Snooze
content.userInfo = ["deeplink": "workout"]
var comps = DateComponents()
comps.weekday = weekday; comps.hour = rHour; comps.minute = rMinute
@@ -204,8 +249,9 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
if checkInOn {
let content = UNMutableNotificationContent()
content.title = "\(program.name) — check in"
content.body = doneToday ? "Great work today!" : "Did you finish? Mark your sets complete."
content.body = doneToday ? "Great work today!" : "Have you done this workout?"
content.sound = .default
content.categoryIdentifier = Self.confirmCatId // Yes / No / Snooze
content.userInfo = ["deeplink": "workout"]
var comps = DateComponents()
comps.weekday = weekday; comps.hour = cHour; comps.minute = cMinute
@@ -384,6 +430,20 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingConfirm")
case Self.missedWorkoutActId:
// "No, I didn't" mark the day missed; the app asks about compensation on next open.
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingMissed")
case Self.snoozeWorkoutActId:
// Re-deliver this same check-in in one hour.
let content = response.notification.request.content.mutableCopy() as! UNMutableNotificationContent
center.add(UNNotificationRequest(
identifier: "kisani.workout.snooze.\(UUID().uuidString)",
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
))
default:
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
Task { @MainActor in

View File

@@ -233,7 +233,16 @@ class WorkoutViewModel: ObservableObject {
@Published var activeProgramId: UUID
@Published var activeSections: [WorkoutSection]
@Published var workoutDates: [String] = []
@Published var missedDates: [String] = [] // days marked "didn't do it"
@Published var compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" programId
@Published var askCompensate: MissedDay? = nil // triggers the compensation sheet
@Published var history: [WorkoutDayLog] = []
struct MissedDay: Identifiable {
let id: String // "yyyy-MM-dd" of the missed day
let programId: UUID? // what was scheduled that day
let programName: String
}
@Published var restTimerSeconds: Int = 60
@Published var restTimerEnabled: Bool = false
@Published var restCountdown: Int = 0
@@ -267,6 +276,8 @@ class WorkoutViewModel: ObservableObject {
private let kWorkoutDates: String
private let kLastActiveDay: String
private let kWorkoutHistory: String
private let kMissedDates: String
private let kCompensations: String
init() {
let uid = AuthManager.shared.currentUser?.id ?? "shared"
@@ -276,6 +287,8 @@ class WorkoutViewModel: ObservableObject {
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
self.kMissedDates = "kisani.workout.missedDates.\(uid)"
self.kCompensations = "kisani.workout.compensations.\(uid)"
// Restore all workout keys from iCloud if missing locally (fresh install / new build)
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.programs.\(uid)")
@@ -345,6 +358,8 @@ class WorkoutViewModel: ObservableObject {
activeSections = blank.sections
}
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
let saved = try? JSONDecoder().decode([WorkoutDayLog].self, from: hData) {
history = saved
@@ -361,6 +376,10 @@ class WorkoutViewModel: ObservableObject {
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
UserDefaults.kisani.set(compensations, forKey: kCompensations)
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
CloudSyncManager.shared.push(value: compensations, forKey: kCompensations)
if let hData = try? JSONEncoder().encode(history) {
UserDefaults.kisani.set(hData, forKey: kWorkoutHistory)
CloudSyncManager.shared.push(data: hData, forKey: kWorkoutHistory)
@@ -454,9 +473,9 @@ class WorkoutViewModel: ObservableObject {
var todaysScheduledProgram: WorkoutProgram? { program(for: Date()) }
/// True when a schedule exists but today has no program a rest day.
/// A compensation scheduled for today counts as a workout day, not rest.
var isRestDayToday: Bool {
let weekday = Calendar.current.component(.weekday, from: Date())
return !schedule.isEmpty && schedule[weekday] == nil
!schedule.isEmpty && program(for: Date()) == nil
}
/// On a new day, make the active program match the day's schedule so the
@@ -560,6 +579,79 @@ class WorkoutViewModel: ObservableObject {
workoutDates.contains(iso.string(from: date))
}
// MARK: - Per-date status (Pending / Done / Missed / Compensation)
enum WorkoutDayStatus { case none, pending, done, missed }
func isWorkoutMissed(on date: Date) -> Bool { missedDates.contains(iso.string(from: date)) }
func isCompensation(on date: Date) -> Bool { compensations[iso.string(from: date)] != nil }
/// Status of the workout scheduled on a specific date. Done wins over missed.
func workoutStatus(on date: Date) -> WorkoutDayStatus {
guard program(for: date) != nil else { return .none }
if isWorkoutComplete(on: date) { return .done }
if isWorkoutMissed(on: date) { return .missed }
return .pending
}
/// Mark one specific day's workout done never touches other occurrences.
func markWorkoutDone(on date: Date) {
let key = iso.string(from: date)
missedDates.removeAll { $0 == key }
logWorkoutCompleted(on: date) // adds to workoutDates, snapshots history, saves
}
/// Mark one specific day's workout missed (undoes a done mark for that day only).
func markWorkoutMissed(on date: Date) {
let key = iso.string(from: date)
workoutDates.removeAll { $0 == key }
if !missedDates.contains(key) { missedDates.append(key) }
save()
}
// MARK: - Compensation (recover a missed workout on a rest day)
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
func upcomingRestDays(within days: Int = 14) -> [Date] {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
return (1...days).compactMap { off -> Date? in
guard let d = cal.date(byAdding: .day, value: off, to: today) else { return nil }
let weekday = cal.component(.weekday, from: d)
guard schedule[weekday] == nil, compensations[iso.string(from: d)] == nil else { return nil }
return d
}
}
func scheduleCompensation(programId: UUID, on date: Date) {
compensations[iso.string(from: date)] = programId.uuidString
save()
}
func removeCompensation(on date: Date) {
compensations.removeValue(forKey: iso.string(from: date))
save()
}
/// Begin the "compensate for this missed day?" flow for a given date.
func promptCompensation(forMissed date: Date) {
let prog = program(for: date)
askCompensate = MissedDay(id: iso.string(from: date),
programId: prog?.id,
programName: prog?.name ?? "Workout")
}
/// Picks up "No, I missed it" tapped on a workout notification (set in background).
@MainActor
func checkPendingMissed() {
let key = "kisani.workout.pendingMissed"
guard let dateStr = UserDefaults.kisani.string(forKey: key) else { return }
UserDefaults.kisani.removeObject(forKey: key)
guard let date = iso.date(from: dateStr) else { return }
markWorkoutMissed(on: date)
promptCompensation(forMissed: date)
}
// MARK: - Program management
func switchProgram(to id: UUID) {
syncBack()
@@ -597,6 +689,12 @@ class WorkoutViewModel: ObservableObject {
}
func program(for date: Date) -> WorkoutProgram? {
// A compensation scheduled on this (rest) day takes the slot.
if let pidStr = compensations[iso.string(from: date)],
let pid = UUID(uuidString: pidStr),
let prog = programs.first(where: { $0.id == pid }) {
return prog
}
let weekday = Calendar.current.component(.weekday, from: date)
guard let pid = schedule[weekday] else { return nil }
return programs.first { $0.id == pid }
@@ -655,6 +753,19 @@ class WorkoutViewModel: ObservableObject {
if wasMarkedDone && restTimerEnabled { startRestTimer() }
}
/// Mark every set of one exercise done/undone (long-press quick action).
func setExerciseDone(sectionId: UUID, exerciseId: UUID, done: Bool) {
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
else { return }
for idx in activeSections[si].exercises[ei].sets.indices {
activeSections[si].exercises[ei].sets[idx].isDone = done
}
if done, sessionStartDate == nil { sessionStartDate = Date() }
syncBack()
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
}
func addSet(sectionId: UUID, to exerciseId: UUID) {
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })

View File

@@ -585,6 +585,14 @@ struct CalendarView: View {
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
workout: workoutVM.program(for: selectedDate),
workoutDone: workoutVM.isWorkoutComplete(on: selectedDate),
workoutMissed: workoutVM.isWorkoutMissed(on: selectedDate),
workoutCompensation: workoutVM.isCompensation(on: selectedDate),
onWorkoutDone: { workoutVM.markWorkoutDone(on: selectedDate) },
onWorkoutMissed: { workoutVM.markWorkoutMissed(on: selectedDate) },
onWorkoutCompensate: {
workoutVM.markWorkoutMissed(on: selectedDate)
workoutVM.promptCompensation(forMissed: selectedDate)
},
onWorkoutTap: {
if let w = workoutVM.program(for: selectedDate),
workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) }
@@ -1562,6 +1570,11 @@ struct DayTimelineView: View {
let calEvents: [EKEvent]
let workout: WorkoutProgram?
var workoutDone: Bool = false // per-DATE completion (from workoutDates), not live sets
var workoutMissed: Bool = false
var workoutCompensation: Bool = false
var onWorkoutDone: (() -> Void)? = nil
var onWorkoutMissed: (() -> Void)? = nil
var onWorkoutCompensate: (() -> Void)? = nil
var onWorkoutTap: (() -> Void)? = nil
var onToggle: ((TaskItem) -> Void)? = nil
var onPin: ((TaskItem) -> Void)? = nil
@@ -1611,11 +1624,13 @@ struct DayTimelineView: View {
// date (workoutDates), never the program's shared live set state.
if let w = workout {
let done = workoutDone
let status = done ? "Done" : workoutMissed ? "Missed" : "Pending"
let kind = workoutCompensation ? "Compensation · " : ""
out.append(DayTLEntry(
timeLabel: "Workout",
title: w.name,
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
color: AppColors.blue,
subtitle: "\(kind)\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s") · \(status)",
color: workoutMissed && !done ? AppColors.accent : AppColors.blue,
isComplete: done, isAllDay: true, sortKey: -2, sortGroup: -1, workoutRef: w
))
}
@@ -1707,6 +1722,9 @@ struct DayTimelineView: View {
)
if let t = entry.taskRef {
row.contextMenu { taskContextMenu(t) }
} else if entry.workoutRef != nil,
onWorkoutDone != nil || onWorkoutMissed != nil {
row.contextMenu { workoutContextMenu }
} else if let ev = entry.eventRef,
onEditEvent != nil || onDeleteEvent != nil,
ev.calendar?.type != .subscription,
@@ -1735,6 +1753,27 @@ struct DayTimelineView: View {
)
}
// Quick actions for the day's workout applies to THIS date only.
@ViewBuilder
private var workoutContextMenu: some View {
if let onWorkoutDone {
Button { onWorkoutDone() } label: {
Label("Mark as Done", systemImage: "checkmark.circle")
}
}
if let onWorkoutMissed {
Button { onWorkoutMissed() } label: {
Label("Mark as Not Done", systemImage: "xmark.circle")
}
}
if let onWorkoutCompensate {
Divider()
Button { onWorkoutCompensate() } label: {
Label("Move to Rest Day / Compensate", systemImage: "arrow.uturn.forward")
}
}
}
// Edit / Delete for a real (non-subscription) calendar event.
@ViewBuilder
private func eventContextMenu(_ event: EKEvent) -> some View {

View File

@@ -91,6 +91,14 @@ struct TodayView: View {
calEvents: todayCalEvents,
workout: workoutVM.program(for: Date()),
workoutDone: workoutVM.isWorkoutComplete(on: Date()),
workoutMissed: workoutVM.isWorkoutMissed(on: Date()),
workoutCompensation: workoutVM.isCompensation(on: Date()),
onWorkoutDone: { workoutVM.markWorkoutDone(on: Date()) },
onWorkoutMissed: { workoutVM.markWorkoutMissed(on: Date()) },
onWorkoutCompensate: {
workoutVM.markWorkoutMissed(on: Date())
workoutVM.promptCompensation(forMissed: Date())
},
onWorkoutTap: {
if let w = workoutVM.program(for: Date()), workoutVM.activeProgramId != w.id {
workoutVM.switchProgram(to: w.id)

View File

@@ -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