Workout: auto-switch to today's program + Rest Day state
The Workout tab kept showing the last-used program (with its old completion) regardless of the weekly schedule, so a rest day still displayed a stale, fully-checked workout. - On a new day, the active program now follows the weekly schedule (applyScheduledProgramForToday in resetSetsForNewDayIfNeeded). - Add isRestDayToday + a Rest Day card shown when today has no scheduled program, with a "Work out anyway" override. Header reads "Rest Day". - Daily set reset (KC-1) unchanged — no day starts pre-checked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -313,10 +313,32 @@ class WorkoutViewModel: ObservableObject {
|
||||
let today = iso.string(from: Date())
|
||||
let last = UserDefaults.kisani.string(forKey: kLastActiveDay)
|
||||
guard last != today else { return }
|
||||
if last != nil { clearAllDoneSets() } // skip the wipe on the very first launch
|
||||
if last != nil {
|
||||
clearAllDoneSets() // skip the wipe on the very first launch
|
||||
applyScheduledProgramForToday() // follow the weekly schedule on a new day
|
||||
}
|
||||
UserDefaults.kisani.set(today, forKey: kLastActiveDay)
|
||||
}
|
||||
|
||||
/// The program assigned to today in the weekly schedule (nil = rest day).
|
||||
var todaysScheduledProgram: WorkoutProgram? { program(for: Date()) }
|
||||
|
||||
/// True when a schedule exists but today has no program — a rest day.
|
||||
var isRestDayToday: Bool {
|
||||
let weekday = Calendar.current.component(.weekday, from: Date())
|
||||
return !schedule.isEmpty && schedule[weekday] == nil
|
||||
}
|
||||
|
||||
/// On a new day, make the active program match the day's schedule so the
|
||||
/// Workout tab shows today's workout instead of yesterday's. Rest days leave
|
||||
/// the active program untouched (the view shows a rest state instead).
|
||||
private func applyScheduledProgramForToday() {
|
||||
guard let scheduled = todaysScheduledProgram, scheduled.id != activeProgramId else { return }
|
||||
activeProgramId = scheduled.id
|
||||
activeSections = scheduled.sections
|
||||
save()
|
||||
}
|
||||
|
||||
/// Unchecks every set across all programs and clears the in-progress session.
|
||||
private func clearAllDoneSets() {
|
||||
for pi in programs.indices {
|
||||
|
||||
@@ -62,6 +62,7 @@ struct WorkoutView: View {
|
||||
@State private var showManage = false
|
||||
@State private var showAddSection = false
|
||||
@State private var isReordering = false
|
||||
@State private var trainAnyway = false // override the rest-day state for today
|
||||
@ObservedObject private var tm = TutorialManager.shared
|
||||
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
@@ -70,6 +71,10 @@ struct WorkoutView: View {
|
||||
return f.string(from: Date())
|
||||
}
|
||||
|
||||
/// Show the rest-day state when today has no scheduled program and the user
|
||||
/// hasn't chosen to train anyway.
|
||||
private var isRestState: Bool { vm.isRestDayToday && !trainAnyway }
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
if isReordering {
|
||||
@@ -85,8 +90,8 @@ struct WorkoutView: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
ProgramIcon(name: vm.workoutEmoji, size: 14, color: AppColors.accent)
|
||||
Text(vm.workoutTitle)
|
||||
ProgramIcon(name: isRestState ? "moon.zzz.fill" : vm.workoutEmoji, size: 14, color: AppColors.accent)
|
||||
Text(isRestState ? "Rest Day" : vm.workoutTitle)
|
||||
.font(AppFonts.sans(15, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
}
|
||||
@@ -102,6 +107,15 @@ struct WorkoutView: View {
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 18, bottom: 4, trailing: 18))
|
||||
.moveDisabled(true)
|
||||
|
||||
if isRestState {
|
||||
// ── Rest Day ──
|
||||
RestDayCard(onTrain: { withAnimation(KisaniSpring.snappy) { trainAnyway = true } })
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 16, leading: 14, bottom: 6, trailing: 14))
|
||||
.moveDisabled(true)
|
||||
} else {
|
||||
|
||||
// ── Dot Grid Progress ──
|
||||
DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets)
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -200,6 +214,7 @@ struct WorkoutView: View {
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 14, bottom: 100, trailing: 14))
|
||||
.moveDisabled(true)
|
||||
} // end non-rest content
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
@@ -537,6 +552,41 @@ struct DotProgressCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rest Day Card
|
||||
struct RestDayCard: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let onTrain: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: 30, weight: .light))
|
||||
.foregroundColor(AppColors.accent)
|
||||
VStack(spacing: 4) {
|
||||
Text("Rest Day")
|
||||
.font(AppFonts.sans(17, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("No workout scheduled today. Recover and come back stronger.")
|
||||
.font(AppFonts.sans(12))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
Button(action: onTrain) {
|
||||
Text("Work out anyway")
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.padding(.horizontal, 16).padding(.vertical, 9)
|
||||
.background(AppColors.accentSoft)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(PressButtonStyle(scale: 0.96))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 28).padding(.horizontal, 16)
|
||||
.cardStyle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Streak Banner
|
||||
struct StreakBannerView: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
Reference in New Issue
Block a user