From 443fe8504f84de52394f3b4508d41329e95d596d Mon Sep 17 00:00:00 2001 From: kutesir Date: Wed, 10 Jun 2026 23:06:58 +0300 Subject: [PATCH] Workout check-in: missed state + rest-day compensation (KC-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- KisaniCal/ContentView.swift | 8 ++ KisaniCal/ISSUES.md | 41 +++++++ KisaniCal/Managers/NotificationManager.swift | 66 ++++++++++- KisaniCal/Models/ExerciseModels.swift | 115 ++++++++++++++++++- KisaniCal/Views/CalendarView.swift | 43 ++++++- KisaniCal/Views/TodayView.swift | 8 ++ KisaniCal/Views/WorkoutView.swift | 93 +++++++++++++++ 7 files changed, 367 insertions(+), 7 deletions(-) diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 4c924b8..0c4dfdf 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -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) } diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index bb135ab..a53b745 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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. diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 50e5485..671863c 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -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 diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index 85c0688..0c130d4 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -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 }) diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index d06bef2..831e5e1 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -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 { diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index 36ea0e0..b243c2d 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -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) diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift index 21d567d..51d09f6 100644 --- a/KisaniCal/Views/WorkoutView.swift +++ b/KisaniCal/Views/WorkoutView.swift @@ -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