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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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