- Workout window: the header ⋯ menu gains "Mark All Complete" and "Clear All Sets" (Manage Workouts preserved). New WorkoutViewModel.setAllSetsDone(_:) ticks every set across all sections/exercises and runs the normal completion/ logging path. - Recurring tasks: completing an occurrence now posts a quiet "✓ Done. Repeats <tomorrow / in 1 week / …>" confirmation via NotificationManager .notifyRecurringCompleted, phrased from the next active occurrence. Hooked into TaskViewModel.toggleOccurrence; un-completing does nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1094 lines
55 KiB
Swift
1094 lines
55 KiB
Swift
import Foundation
|
||
import Combine
|
||
|
||
// MARK: - Exercise Models
|
||
struct ExerciseSet: Identifiable, Codable {
|
||
var id = UUID()
|
||
var weight: Double = 0
|
||
var reps: Int = 10
|
||
var isDone: Bool = false
|
||
}
|
||
|
||
struct Exercise: Identifiable, Codable {
|
||
var id = UUID()
|
||
var name: String
|
||
var sfSymbol: String
|
||
var colorIndex: Int = 0
|
||
var sets: [ExerciseSet]
|
||
var isExpanded: Bool = true
|
||
var isDumbbell: Bool = false // per-side input → total = weight × 2
|
||
|
||
var totalSets: Int { sets.count }
|
||
var doneSets: Int { sets.filter(\.isDone).count }
|
||
var isPartialDone: Bool { doneSets > 0 && doneSets < totalSets }
|
||
var isAllDone: Bool { doneSets == totalSets && totalSets > 0 }
|
||
}
|
||
|
||
// MARK: - Workout Section
|
||
struct WorkoutSection: Identifiable, Codable {
|
||
var id = UUID()
|
||
var name: String
|
||
var exercises: [Exercise]
|
||
var isExpanded: Bool = true
|
||
|
||
var totalSets: Int { exercises.reduce(0) { $0 + $1.sets.count } }
|
||
var doneSets: Int { exercises.reduce(0) { $0 + $1.doneSets } }
|
||
var exerciseCount: Int { exercises.count }
|
||
}
|
||
|
||
// MARK: - Workout Program
|
||
struct WorkoutProgram: Identifiable, Codable {
|
||
let id: UUID
|
||
var name: String
|
||
var emoji: String
|
||
var colorIndex: Int
|
||
var sections: [WorkoutSection]
|
||
|
||
var allExercises: [Exercise] { sections.flatMap(\.exercises) }
|
||
var totalSets: Int { sections.reduce(0) { $0 + $1.totalSets } }
|
||
var doneSets: Int { sections.reduce(0) { $0 + $1.doneSets } }
|
||
var totalExercises: Int { sections.reduce(0) { $0 + $1.exerciseCount } }
|
||
|
||
init(id: UUID = UUID(), name: String, emoji: String, colorIndex: Int = 0, sections: [WorkoutSection] = []) {
|
||
self.id = id; self.name = name; self.emoji = emoji
|
||
self.colorIndex = colorIndex; self.sections = sections
|
||
}
|
||
}
|
||
|
||
// MARK: - Per-day Workout History
|
||
|
||
/// A snapshot of what was logged on a given day, captured before the daily reset.
|
||
struct WorkoutDayLog: Identifiable, Codable {
|
||
var id: String { date } // "yyyy-MM-dd"
|
||
let date: String
|
||
let programName: String
|
||
let programEmoji: String
|
||
let exercises: [LoggedExercise]
|
||
|
||
var totalSets: Int { exercises.reduce(0) { $0 + $1.sets.count } }
|
||
var doneSets: Int { exercises.reduce(0) { $0 + $1.sets.filter(\.done).count } }
|
||
/// Total training volume in kg for completed sets (Σ weight × reps).
|
||
var volume: Double {
|
||
exercises.reduce(0) { sum, ex in
|
||
sum + ex.sets.reduce(0) { $0 + ($1.done ? $1.weight * Double($1.reps) : 0) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Workout stats aggregation
|
||
|
||
enum StatPeriod: String, CaseIterable, Identifiable {
|
||
case day = "Day", week = "Week", month = "Month"
|
||
var id: String { rawValue }
|
||
var calComponent: Calendar.Component {
|
||
switch self { case .day: return .day; case .week: return .weekOfYear; case .month: return .month }
|
||
}
|
||
}
|
||
|
||
struct WorkoutStat {
|
||
var workouts: Int = 0
|
||
var sets: Int = 0
|
||
var volume: Double = 0
|
||
}
|
||
|
||
struct StatBucket: Identifiable {
|
||
let id = UUID()
|
||
let label: String
|
||
let stat: WorkoutStat
|
||
}
|
||
|
||
struct LoggedExercise: Identifiable, Codable {
|
||
var id = UUID()
|
||
let name: String
|
||
let sfSymbol: String
|
||
let sets: [LoggedSet]
|
||
var doneSets: Int { sets.filter(\.done).count }
|
||
}
|
||
|
||
struct LoggedSet: Codable {
|
||
let weight: Double
|
||
let reps: Int
|
||
let done: Bool
|
||
}
|
||
|
||
// MARK: - Exercise Template Library
|
||
enum ExerciseMuscle: String, CaseIterable, Identifiable {
|
||
var id: String { rawValue }
|
||
case chest = "Chest"
|
||
case back = "Back"
|
||
case legs = "Legs"
|
||
case shoulders = "Shoulders"
|
||
case arms = "Arms"
|
||
case core = "Core"
|
||
case cardio = "Cardio"
|
||
|
||
var sfSymbol: String {
|
||
switch self {
|
||
case .chest: return "figure.strengthtraining.traditional"
|
||
case .back: return "figure.pull.up"
|
||
case .legs: return "figure.squat"
|
||
case .shoulders: return "figure.arms.open"
|
||
case .arms: return "dumbbell.fill"
|
||
case .core: return "figure.core.training"
|
||
case .cardio: return "figure.run"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ExerciseTemplate: Identifiable {
|
||
let id = UUID()
|
||
let name: String
|
||
let sfSymbol: String
|
||
let muscle: ExerciseMuscle
|
||
var isDumbbell: Bool = false
|
||
}
|
||
|
||
let exerciseLibrary: [ExerciseTemplate] = [
|
||
// Chest
|
||
ExerciseTemplate(name: "Bench Press", sfSymbol: "figure.strengthtraining.traditional", muscle: .chest),
|
||
ExerciseTemplate(name: "Incline Bench Press", sfSymbol: "figure.strengthtraining.traditional", muscle: .chest, isDumbbell: true),
|
||
ExerciseTemplate(name: "Incline Dumbbell Press", sfSymbol: "dumbbell.fill", muscle: .chest, isDumbbell: true),
|
||
ExerciseTemplate(name: "Decline Bench Press", sfSymbol: "figure.strengthtraining.traditional", muscle: .chest),
|
||
ExerciseTemplate(name: "Cable Fly", sfSymbol: "figure.strengthtraining.functional", muscle: .chest),
|
||
ExerciseTemplate(name: "Chest Flys", sfSymbol: "dumbbell.fill", muscle: .chest, isDumbbell: true),
|
||
ExerciseTemplate(name: "Push-ups", sfSymbol: "figure.gymnastics", muscle: .chest),
|
||
ExerciseTemplate(name: "Chest Dips", sfSymbol: "figure.gymnastics", muscle: .chest),
|
||
ExerciseTemplate(name: "Cable Crossover", sfSymbol: "figure.strengthtraining.functional", muscle: .chest),
|
||
// Back
|
||
ExerciseTemplate(name: "Pull-ups", sfSymbol: "figure.pull.up", muscle: .back),
|
||
ExerciseTemplate(name: "Wide Pull-ups", sfSymbol: "figure.pull.up", muscle: .back),
|
||
ExerciseTemplate(name: "Lat Pulldown", sfSymbol: "figure.pull.up", muscle: .back),
|
||
ExerciseTemplate(name: "Barbell Row", sfSymbol: "figure.strengthtraining.traditional", muscle: .back),
|
||
ExerciseTemplate(name: "Seated Cable Row", sfSymbol: "figure.rowing", muscle: .back),
|
||
ExerciseTemplate(name: "Cable Row", sfSymbol: "figure.rowing", muscle: .back),
|
||
ExerciseTemplate(name: "Incline High Row", sfSymbol: "figure.strengthtraining.functional", muscle: .back),
|
||
ExerciseTemplate(name: "Face Pull", sfSymbol: "figure.strengthtraining.functional", muscle: .back),
|
||
ExerciseTemplate(name: "Deadlift", sfSymbol: "figure.strengthtraining.traditional", muscle: .back),
|
||
ExerciseTemplate(name: "T-Bar Row", sfSymbol: "figure.strengthtraining.traditional", muscle: .back),
|
||
ExerciseTemplate(name: "Shrugs", sfSymbol: "figure.arms.open", muscle: .back),
|
||
ExerciseTemplate(name: "Lat Pulldown Wide Grip", sfSymbol: "figure.pull.up", muscle: .back),
|
||
// Legs
|
||
ExerciseTemplate(name: "Squat", sfSymbol: "figure.squat", muscle: .legs),
|
||
ExerciseTemplate(name: "Hack Squat", sfSymbol: "figure.squat", muscle: .legs),
|
||
ExerciseTemplate(name: "Romanian Deadlift", sfSymbol: "figure.strengthtraining.traditional", muscle: .legs),
|
||
ExerciseTemplate(name: "Leg Press", sfSymbol: "figure.strengthtraining.functional", muscle: .legs),
|
||
ExerciseTemplate(name: "Leg Extension", sfSymbol: "figure.strengthtraining.functional", muscle: .legs),
|
||
ExerciseTemplate(name: "Adductor Machine", sfSymbol: "figure.strengthtraining.functional", muscle: .legs),
|
||
ExerciseTemplate(name: "Lying Leg Curl", sfSymbol: "figure.strengthtraining.functional", muscle: .legs),
|
||
ExerciseTemplate(name: "Seated Leg Curl", sfSymbol: "figure.strengthtraining.functional", muscle: .legs),
|
||
ExerciseTemplate(name: "Hamstring Curl", sfSymbol: "figure.strengthtraining.functional", muscle: .legs),
|
||
ExerciseTemplate(name: "Standing Calf Raise", sfSymbol: "figure.walk", muscle: .legs),
|
||
ExerciseTemplate(name: "Seated Calf Raise", sfSymbol: "figure.strengthtraining.functional", muscle: .legs),
|
||
ExerciseTemplate(name: "Bulgarian Split Squat", sfSymbol: "figure.squat", muscle: .legs),
|
||
ExerciseTemplate(name: "Lunges", sfSymbol: "figure.walk", muscle: .legs),
|
||
ExerciseTemplate(name: "Stiff-Legged Deadlift", sfSymbol: "figure.strengthtraining.traditional", muscle: .legs),
|
||
ExerciseTemplate(name: "Leg Curl", sfSymbol: "figure.strengthtraining.functional", muscle: .legs),
|
||
// Shoulders
|
||
ExerciseTemplate(name: "Overhead Press", sfSymbol: "figure.arms.open", muscle: .shoulders),
|
||
ExerciseTemplate(name: "Dumbbell Shoulder Press",sfSymbol: "dumbbell.fill", muscle: .shoulders, isDumbbell: true),
|
||
ExerciseTemplate(name: "Arnold Press", sfSymbol: "dumbbell.fill", muscle: .shoulders, isDumbbell: true),
|
||
ExerciseTemplate(name: "Lateral Raises", sfSymbol: "figure.arms.open", muscle: .shoulders, isDumbbell: true),
|
||
ExerciseTemplate(name: "Front Raises", sfSymbol: "figure.arms.open", muscle: .shoulders, isDumbbell: true),
|
||
ExerciseTemplate(name: "Cable Lateral Raise", sfSymbol: "figure.strengthtraining.functional", muscle: .shoulders),
|
||
ExerciseTemplate(name: "Reverse Fly", sfSymbol: "dumbbell.fill", muscle: .shoulders, isDumbbell: true),
|
||
// Arms
|
||
ExerciseTemplate(name: "Bicep Curls", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true),
|
||
ExerciseTemplate(name: "Hammer Curls", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true),
|
||
ExerciseTemplate(name: "Incline Dumbbell Curls", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true),
|
||
ExerciseTemplate(name: "Incline Hammer Curl", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true),
|
||
ExerciseTemplate(name: "Barbell Curls", sfSymbol: "figure.strengthtraining.traditional", muscle: .arms),
|
||
ExerciseTemplate(name: "Preacher Curl", sfSymbol: "dumbbell.fill", muscle: .arms),
|
||
ExerciseTemplate(name: "Concentration Curls", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true),
|
||
ExerciseTemplate(name: "Dips", sfSymbol: "figure.gymnastics", muscle: .arms),
|
||
ExerciseTemplate(name: "Tricep Dips", sfSymbol: "figure.gymnastics", muscle: .arms),
|
||
ExerciseTemplate(name: "Tricep Pushdown", sfSymbol: "figure.strengthtraining.functional", muscle: .arms),
|
||
ExerciseTemplate(name: "Straight Bar Pushdown", sfSymbol: "figure.strengthtraining.functional", muscle: .arms),
|
||
ExerciseTemplate(name: "Skull Crushers", sfSymbol: "figure.strengthtraining.traditional", muscle: .arms),
|
||
ExerciseTemplate(name: "Overhead Tricep Ext", sfSymbol: "dumbbell.fill", muscle: .arms),
|
||
ExerciseTemplate(name: "Rope Pushdown", sfSymbol: "figure.strengthtraining.functional", muscle: .arms),
|
||
// Core
|
||
ExerciseTemplate(name: "Plank", sfSymbol: "figure.core.training", muscle: .core),
|
||
ExerciseTemplate(name: "Crunches", sfSymbol: "figure.core.training", muscle: .core),
|
||
ExerciseTemplate(name: "Ab Wheel Rollout", sfSymbol: "figure.roll", muscle: .core),
|
||
ExerciseTemplate(name: "Russian Twists", sfSymbol: "figure.cross.training", muscle: .core),
|
||
ExerciseTemplate(name: "Hanging Leg Raise", sfSymbol: "figure.pull.up", muscle: .core),
|
||
ExerciseTemplate(name: "Cable Crunches", sfSymbol: "figure.cooldown", muscle: .core),
|
||
// Cardio
|
||
ExerciseTemplate(name: "Treadmill", sfSymbol: "figure.run", muscle: .cardio),
|
||
ExerciseTemplate(name: "Cycling", sfSymbol: "figure.outdoor.cycle", muscle: .cardio),
|
||
ExerciseTemplate(name: "Jump Rope", sfSymbol: "figure.jumprope", muscle: .cardio),
|
||
ExerciseTemplate(name: "Rowing Machine", sfSymbol: "figure.rowing", muscle: .cardio),
|
||
ExerciseTemplate(name: "Stair Climber", sfSymbol: "figure.stair.stepper", muscle: .cardio),
|
||
]
|
||
|
||
let exerciseSFSymbol: [String: String] = Dictionary(
|
||
uniqueKeysWithValues: exerciseLibrary.map { ($0.name, $0.sfSymbol) }
|
||
)
|
||
|
||
// MARK: - Workout View Model
|
||
@MainActor
|
||
class WorkoutViewModel: ObservableObject {
|
||
@Published var programs: [WorkoutProgram]
|
||
@Published var schedule: [Int: UUID]
|
||
@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
|
||
@Published var restTimerTotal: Int = 0
|
||
@Published var sessionStartDate: Date? = nil
|
||
|
||
var streakDays: Int {
|
||
let cal = Calendar.current
|
||
let fmt = iso; let dateSet = Set(workoutDates)
|
||
var streak = 0
|
||
var check = cal.startOfDay(for: Date())
|
||
if !dateSet.contains(fmt.string(from: check)) {
|
||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||
}
|
||
while dateSet.contains(fmt.string(from: check)) {
|
||
streak += 1
|
||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||
}
|
||
return streak
|
||
}
|
||
|
||
private let iso: DateFormatter = {
|
||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||
}()
|
||
@Published var showAddExercise: Bool = false
|
||
@Published var addExerciseTargetSectionId: UUID?
|
||
|
||
private let kPrograms: String
|
||
private let kSchedule: String
|
||
private let kActivePid: String
|
||
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"
|
||
self.kPrograms = "kisani.workout.programs.\(uid)"
|
||
self.kSchedule = "kisani.workout.schedule.\(uid)"
|
||
self.kActivePid = "kisani.workout.activePid.\(uid)"
|
||
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)")
|
||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)")
|
||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(uid)")
|
||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)")
|
||
|
||
// ── Attempt to restore persisted state ──
|
||
if let data = UserDefaults.kisani.data(forKey: kPrograms),
|
||
let saved = try? JSONDecoder().decode([WorkoutProgram].self, from: data),
|
||
!saved.isEmpty {
|
||
|
||
// Migrate any legacy emoji → SF Symbol name
|
||
let emojiMap: [String: String] = [
|
||
"🏋️": "figure.strengthtraining.traditional",
|
||
"💪": "figure.strengthtraining.functional",
|
||
"🔄": "figure.arms.open",
|
||
"🦵": "figure.run",
|
||
"⚡": "bolt.fill",
|
||
"⚡️": "bolt.fill",
|
||
"🏃": "figure.run",
|
||
"🤸": "figure.yoga",
|
||
"🧘": "figure.yoga",
|
||
"🚴": "figure.cycling",
|
||
"🥊": "figure.boxing",
|
||
"🎯": "figure.core.training",
|
||
"🫀": "heart.fill",
|
||
"🔥": "bolt.fill",
|
||
"💥": "figure.highintensity.intervaltraining"
|
||
]
|
||
let needsMigration = saved.contains(where: { emojiMap[$0.emoji] != nil })
|
||
let migrated: [WorkoutProgram] = needsMigration
|
||
? saved.map { p in
|
||
guard let rep = emojiMap[p.emoji] else { return p }
|
||
var copy = p; copy.emoji = rep; return copy
|
||
}
|
||
: saved
|
||
|
||
var sched: [Int: UUID] = [:]
|
||
if let dict = UserDefaults.kisani.dictionary(forKey: kSchedule) as? [String: String] {
|
||
for (k, v) in dict {
|
||
if let day = Int(k), let uid = UUID(uuidString: v) { sched[day] = uid }
|
||
}
|
||
}
|
||
schedule = sched
|
||
|
||
var pid = saved[0].id
|
||
if let str = UserDefaults.kisani.string(forKey: kActivePid),
|
||
let uid = UUID(uuidString: str),
|
||
saved.contains(where: { $0.id == uid }) { pid = uid }
|
||
activeProgramId = pid
|
||
// Use local `migrated` (not self.programs) — self is partially initialized here
|
||
programs = migrated
|
||
activeSections = migrated.first { $0.id == pid }?.sections ?? migrated[0].sections
|
||
|
||
if needsMigration { save() }
|
||
|
||
} else {
|
||
// ── First launch: start blank so the user builds their own routine ──
|
||
let blank = WorkoutProgram(
|
||
name: "My Workout", emoji: "dumbbell.fill", colorIndex: 0,
|
||
sections: [WorkoutSection(name: "Main", exercises: [])]
|
||
)
|
||
programs = [blank]
|
||
schedule = [:]
|
||
activeProgramId = blank.id
|
||
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
|
||
}
|
||
resetSetsForNewDayIfNeeded()
|
||
}
|
||
|
||
private func save() {
|
||
if let data = try? JSONEncoder().encode(programs) {
|
||
UserDefaults.kisani.set(data, forKey: kPrograms)
|
||
CloudSyncManager.shared.push(data: data, forKey: kPrograms)
|
||
}
|
||
let schedDict = schedule.reduce(into: [String: String]()) { $0[String($1.key)] = $1.value.uuidString }
|
||
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)
|
||
}
|
||
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
|
||
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
|
||
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
|
||
}
|
||
|
||
// MARK: - Daily Reset
|
||
/// A fresh calendar day starts a clean log. If the checkbox state currently on
|
||
/// screen belongs to an earlier day, wipe every set's `isDone` so re-running the
|
||
/// same program on back-to-back days (e.g. Shoulders on Mon AND Tue) starts
|
||
/// unchecked. Streak history (`workoutDates`) is never touched here.
|
||
/// Safe to call repeatedly — it only does work when the day actually changes.
|
||
func resetSetsForNewDayIfNeeded() {
|
||
let today = iso.string(from: Date())
|
||
let last = UserDefaults.kisani.string(forKey: kLastActiveDay)
|
||
guard last != today else { return }
|
||
if let last = last { // skip on the very first launch
|
||
snapshotDay(last) // save what was logged before wiping it
|
||
clearAllDoneSets()
|
||
applyScheduledProgramForToday() // follow the weekly schedule on a new day
|
||
}
|
||
UserDefaults.kisani.set(today, forKey: kLastActiveDay)
|
||
}
|
||
|
||
/// History newest-first, for the history view.
|
||
var historySorted: [WorkoutDayLog] { history.sorted { $0.date > $1.date } }
|
||
|
||
/// Capture the active program's completed work for `dayKey` into history.
|
||
/// Only records days where at least one set was done; updates in place.
|
||
func snapshotDay(_ dayKey: String) {
|
||
let logged: [LoggedExercise] = activeSections.flatMap { section in
|
||
section.exercises.map { ex in
|
||
LoggedExercise(name: ex.name, sfSymbol: ex.sfSymbol,
|
||
sets: ex.sets.map { LoggedSet(weight: $0.weight, reps: $0.reps, done: $0.isDone) })
|
||
}
|
||
}
|
||
guard logged.contains(where: { $0.doneSets > 0 }) else { return }
|
||
let log = WorkoutDayLog(date: dayKey,
|
||
programName: activeProgram?.name ?? "Workout",
|
||
programEmoji: activeProgram?.emoji ?? "dumbbell.fill",
|
||
exercises: logged)
|
||
history.removeAll { $0.date == dayKey }
|
||
history.append(log)
|
||
save()
|
||
}
|
||
|
||
// MARK: - Stats (daily / weekly / monthly performance)
|
||
|
||
/// History logs whose date falls inside `interval`.
|
||
private func logs(in interval: DateInterval) -> [WorkoutDayLog] {
|
||
history.filter { guard let d = iso.date(from: $0.date) else { return false }
|
||
return interval.contains(d) }
|
||
}
|
||
|
||
/// The calendar interval for `period`, shifted back by `offset` periods (0 = current).
|
||
func statInterval(_ period: StatPeriod, offset: Int) -> DateInterval {
|
||
let cal = Calendar.current
|
||
let anchor = cal.date(byAdding: period.calComponent, value: offset, to: Date()) ?? Date()
|
||
return cal.dateInterval(of: period.calComponent, for: anchor)
|
||
?? DateInterval(start: cal.startOfDay(for: anchor), duration: 86400)
|
||
}
|
||
|
||
/// Aggregate workout stats for one period (offset 0 = current, -1 = previous).
|
||
func stat(_ period: StatPeriod, offset: Int = 0) -> WorkoutStat {
|
||
let logs = logs(in: statInterval(period, offset: offset))
|
||
return WorkoutStat(
|
||
workouts: logs.count,
|
||
sets: logs.reduce(0) { $0 + $1.doneSets },
|
||
volume: logs.reduce(0) { $0 + $1.volume }
|
||
)
|
||
}
|
||
|
||
/// A series of `count` recent periods, oldest → newest, for charting.
|
||
func statBuckets(_ period: StatPeriod, count: Int) -> [StatBucket] {
|
||
let fmt = DateFormatter()
|
||
switch period {
|
||
case .day: fmt.dateFormat = "EEE"
|
||
case .week: fmt.dateFormat = "MMM d"
|
||
case .month: fmt.dateFormat = "MMM"
|
||
}
|
||
return (0..<count).reversed().map { back in
|
||
let interval = statInterval(period, offset: -back)
|
||
return StatBucket(label: fmt.string(from: interval.start), stat: stat(period, offset: -back))
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
/// A compensation scheduled for today counts as a workout day, not rest.
|
||
var isRestDayToday: Bool {
|
||
!schedule.isEmpty && program(for: Date()) == 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 {
|
||
for si in programs[pi].sections.indices {
|
||
for ei in programs[pi].sections[si].exercises.indices {
|
||
for setIdx in programs[pi].sections[si].exercises[ei].sets.indices {
|
||
programs[pi].sections[si].exercises[ei].sets[setIdx].isDone = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
activeSections = programs.first { $0.id == activeProgramId }?.sections ?? activeSections
|
||
sessionStartDate = nil
|
||
save()
|
||
}
|
||
|
||
func logWorkoutCompleted(on date: Date = Date()) {
|
||
let key = iso.string(from: date)
|
||
snapshotDay(key) // record today's session in history
|
||
guard !workoutDates.contains(key) else { return }
|
||
workoutDates.append(key)
|
||
save()
|
||
let start = sessionStartDate ?? date.addingTimeInterval(-3600)
|
||
Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) }
|
||
sessionStartDate = nil
|
||
}
|
||
|
||
// Pull completed workout dates from Apple Health and merge into local streak
|
||
@MainActor
|
||
func syncFromHealthKit() async {
|
||
guard HealthKitManager.shared.authorized else { return }
|
||
guard let ninetyDaysAgo = Calendar.current.date(byAdding: .day, value: -90, to: Date()) else { return }
|
||
let hkDates = await HealthKitManager.shared.fetchWorkoutDates(from: ninetyDaysAgo, to: Date())
|
||
guard !hkDates.isEmpty else { return }
|
||
var newSet = Set(workoutDates); var updated = false
|
||
for d in hkDates where !newSet.contains(d) { newSet.insert(d); updated = true }
|
||
if updated { workoutDates = Array(newSet); save() }
|
||
}
|
||
|
||
// Called on foreground — picks up "Yes I did it" tapped in notification
|
||
@MainActor
|
||
func checkPendingHealthConfirm() {
|
||
let key = "kisani.workout.pendingConfirm"
|
||
guard let dateStr = UserDefaults.kisani.string(forKey: key) else { return }
|
||
UserDefaults.kisani.removeObject(forKey: key)
|
||
guard !workoutDates.contains(dateStr) else { return }
|
||
workoutDates.append(dateStr)
|
||
save()
|
||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||
if let date = fmt.date(from: dateStr) {
|
||
let s = Calendar.current.startOfDay(for: date)
|
||
let e = Calendar.current.date(byAdding: .hour, value: 1, to: s) ?? s
|
||
Task { await HealthKitManager.shared.saveWorkout(start: s, end: e) }
|
||
}
|
||
}
|
||
|
||
func seedStreak(days: Int) {
|
||
let cal = Calendar.current
|
||
var newDates = Set(workoutDates)
|
||
for i in 0..<days {
|
||
let d = cal.date(byAdding: .day, value: -i, to: cal.startOfDay(for: Date()))!
|
||
newDates.insert(iso.string(from: d))
|
||
}
|
||
workoutDates = Array(newDates)
|
||
save()
|
||
}
|
||
|
||
func setScheduleForDays(_ weekdays: Set<Int>) {
|
||
schedule = [:]
|
||
for day in weekdays { schedule[day] = activeProgramId }
|
||
save()
|
||
}
|
||
|
||
var activeProgram: WorkoutProgram? { programs.first { $0.id == activeProgramId } }
|
||
var workoutTitle: String { activeProgram?.name ?? "Workout" }
|
||
var workoutEmoji: String { activeProgram?.emoji ?? "dumbbell.fill" }
|
||
var totalSets: Int { activeSections.reduce(0) { $0 + $1.totalSets } }
|
||
var doneSets: Int { activeSections.reduce(0) { $0 + $1.doneSets } }
|
||
var progress: Double { totalSets > 0 ? Double(doneSets) / Double(totalSets) : 0 }
|
||
|
||
/// True if today already counts toward the streak — either marked complete in-app
|
||
/// or detected from Apple Health (both land in `workoutDates`).
|
||
var isTodayRecorded: Bool { workoutDates.contains(iso.string(from: Date())) }
|
||
|
||
/// Whether a workout was actually completed on a specific calendar date.
|
||
/// Per-date (from `workoutDates`), NOT the program's live set state — so
|
||
/// finishing one occurrence never marks other days of the same program.
|
||
func isWorkoutComplete(on date: Date) -> Bool {
|
||
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()
|
||
guard let prog = programs.first(where: { $0.id == id }) else { return }
|
||
activeProgramId = id
|
||
activeSections = prog.sections
|
||
save()
|
||
}
|
||
|
||
func addProgram(name: String, emoji: String, colorIndex: Int = 0) {
|
||
let defaultSection = WorkoutSection(name: "Main", exercises: [])
|
||
programs.append(WorkoutProgram(name: name, emoji: emoji, colorIndex: colorIndex, sections: [defaultSection]))
|
||
save()
|
||
}
|
||
|
||
func deleteProgram(_ id: UUID) {
|
||
guard id != activeProgramId, programs.count > 1 else { return }
|
||
programs.removeAll { $0.id == id }
|
||
schedule = schedule.filter { $0.value != id }
|
||
save()
|
||
}
|
||
|
||
func renameProgram(_ id: UUID, name: String, emoji: String? = nil) {
|
||
guard let idx = programs.firstIndex(where: { $0.id == id }) else { return }
|
||
programs[idx].name = name
|
||
if let emoji { programs[idx].emoji = emoji }
|
||
if id == activeProgramId { objectWillChange.send() }
|
||
save()
|
||
}
|
||
|
||
func setSchedule(weekday: Int, programId: UUID?) {
|
||
if let pid = programId { schedule[weekday] = pid }
|
||
else { schedule.removeValue(forKey: weekday) }
|
||
save()
|
||
}
|
||
|
||
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 }
|
||
}
|
||
|
||
// MARK: - Section management
|
||
func addSection(name: String) {
|
||
activeSections.append(WorkoutSection(name: name, exercises: []))
|
||
syncBack()
|
||
}
|
||
|
||
func deleteSection(_ id: UUID) {
|
||
activeSections.removeAll { $0.id == id }
|
||
syncBack()
|
||
}
|
||
|
||
func renameSection(_ id: UUID, name: String) {
|
||
guard let si = activeSections.firstIndex(where: { $0.id == id }) else { return }
|
||
activeSections[si].name = name
|
||
syncBack()
|
||
}
|
||
|
||
func moveSectionUp(_ id: UUID) {
|
||
guard let idx = activeSections.firstIndex(where: { $0.id == id }), idx > 0 else { return }
|
||
activeSections.swapAt(idx, idx - 1)
|
||
syncBack()
|
||
}
|
||
|
||
func moveSectionDown(_ id: UUID) {
|
||
guard let idx = activeSections.firstIndex(where: { $0.id == id }),
|
||
idx < activeSections.count - 1 else { return }
|
||
activeSections.swapAt(idx, idx + 1)
|
||
syncBack()
|
||
}
|
||
|
||
func toggleSectionExpanded(_ id: UUID) {
|
||
guard let si = activeSections.firstIndex(where: { $0.id == id }) else { return }
|
||
activeSections[si].isExpanded.toggle()
|
||
}
|
||
|
||
// MARK: - Exercise management
|
||
func toggleSet(sectionId: UUID, exerciseId: UUID, setId: UUID) {
|
||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId }),
|
||
let setIdx = activeSections[si].exercises[ei].sets.firstIndex(where: { $0.id == setId })
|
||
else { return }
|
||
let wasMarkedDone = !activeSections[si].exercises[ei].sets[setIdx].isDone
|
||
activeSections[si].exercises[ei].sets[setIdx].isDone.toggle()
|
||
// The current checkbox state now belongs to today.
|
||
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
|
||
if sessionStartDate == nil, activeSections[si].exercises[ei].sets[setIdx].isDone {
|
||
sessionStartDate = Date()
|
||
}
|
||
syncBack()
|
||
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
|
||
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() }
|
||
}
|
||
|
||
/// Mark every set of the active workout done/undone — the "Select all & mark
|
||
/// complete" quick action. Logs completion when everything is checked.
|
||
func setAllSetsDone(_ done: Bool) {
|
||
for si in activeSections.indices {
|
||
for ei in activeSections[si].exercises.indices {
|
||
for idx in activeSections[si].exercises[ei].sets.indices {
|
||
activeSections[si].exercises[ei].sets[idx].isDone = done
|
||
}
|
||
}
|
||
}
|
||
if done {
|
||
if sessionStartDate == nil { sessionStartDate = Date() }
|
||
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
|
||
}
|
||
syncBack()
|
||
if done, 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 })
|
||
else { return }
|
||
let last = activeSections[si].exercises[ei].sets.last
|
||
activeSections[si].exercises[ei].sets.append(ExerciseSet(weight: last?.weight ?? 0, reps: last?.reps ?? 10))
|
||
syncBack()
|
||
}
|
||
|
||
// Active-program convenience wrappers
|
||
func addExercise(to sectionId: UUID, name: String, setsCount: Int, repsCount: Int) {
|
||
addExercise(programId: activeProgramId, sectionId: sectionId, name: name, setsCount: setsCount, repsCount: repsCount)
|
||
}
|
||
|
||
func deleteExercise(sectionId: UUID, exerciseId: UUID) {
|
||
deleteExercise(programId: activeProgramId, sectionId: sectionId, exerciseId: exerciseId)
|
||
}
|
||
|
||
func moveExercise(sectionId: UUID, from: IndexSet, to: Int) {
|
||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }) else { return }
|
||
activeSections[si].exercises.move(fromOffsets: from, toOffset: to)
|
||
syncBack()
|
||
}
|
||
|
||
/// Relocate an exercise (within or across sections) for any program via drag-and-drop.
|
||
/// Drops the exercise just before `beforeId`; pass nil to append to the target section.
|
||
func moveExercise(programId: UUID, exerciseId: UUID, toSection targetSectionId: UUID, before beforeId: UUID?) {
|
||
guard exerciseId != beforeId else { return }
|
||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||
|
||
// Remove from its current section.
|
||
var moved: Exercise?
|
||
for si in programs[pi].sections.indices {
|
||
if let ei = programs[pi].sections[si].exercises.firstIndex(where: { $0.id == exerciseId }) {
|
||
moved = programs[pi].sections[si].exercises.remove(at: ei)
|
||
break
|
||
}
|
||
}
|
||
guard let ex = moved,
|
||
let ti = programs[pi].sections.firstIndex(where: { $0.id == targetSectionId }) else { return }
|
||
|
||
// Insert before the target row, or append if none specified.
|
||
if let beforeId, let idx = programs[pi].sections[ti].exercises.firstIndex(where: { $0.id == beforeId }) {
|
||
programs[pi].sections[ti].exercises.insert(ex, at: idx)
|
||
} else {
|
||
programs[pi].sections[ti].exercises.append(ex)
|
||
}
|
||
|
||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||
save()
|
||
}
|
||
|
||
// MARK: - Universal (any program) operations
|
||
func addExercise(programId: UUID, sectionId: UUID, name: String, setsCount: Int, repsCount: Int) {
|
||
guard let pi = programs.firstIndex(where: { $0.id == programId }),
|
||
let si = programs[pi].sections.firstIndex(where: { $0.id == sectionId })
|
||
else { return }
|
||
let template = exerciseLibrary.first { $0.name == name }
|
||
let symbol = template?.sfSymbol ?? "figure.strengthtraining.functional"
|
||
let isDumbb = template?.isDumbbell ?? false
|
||
let sets = (0..<setsCount).map { _ in ExerciseSet(weight: 0, reps: repsCount) }
|
||
let colorIdx = programs[pi].allExercises.count % 4
|
||
programs[pi].sections[si].exercises.append(
|
||
Exercise(name: name, sfSymbol: symbol, colorIndex: colorIdx, sets: sets, isDumbbell: isDumbb)
|
||
)
|
||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||
save()
|
||
}
|
||
|
||
func deleteExercise(programId: UUID, sectionId: UUID, exerciseId: UUID) {
|
||
guard let pi = programs.firstIndex(where: { $0.id == programId }),
|
||
let si = programs[pi].sections.firstIndex(where: { $0.id == sectionId })
|
||
else { return }
|
||
programs[pi].sections[si].exercises.removeAll { $0.id == exerciseId }
|
||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||
save()
|
||
}
|
||
|
||
func addSection(to programId: UUID, name: String) {
|
||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||
programs[pi].sections.append(WorkoutSection(name: name, exercises: []))
|
||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||
save()
|
||
}
|
||
|
||
func deleteSection(from programId: UUID, sectionId: UUID) {
|
||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||
programs[pi].sections.removeAll { $0.id == sectionId }
|
||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||
save()
|
||
}
|
||
|
||
func renameSection(in programId: UUID, sectionId: UUID, name: String) {
|
||
guard let pi = programs.firstIndex(where: { $0.id == programId }),
|
||
let si = programs[pi].sections.firstIndex(where: { $0.id == sectionId })
|
||
else { return }
|
||
programs[pi].sections[si].name = name
|
||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||
save()
|
||
}
|
||
|
||
func toggleExpanded(sectionId: UUID, exerciseId: UUID) {
|
||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||
else { return }
|
||
activeSections[si].exercises[ei].isExpanded.toggle()
|
||
}
|
||
|
||
func toggleDumbbell(sectionId: UUID, exerciseId: UUID) {
|
||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||
else { return }
|
||
activeSections[si].exercises[ei].isDumbbell.toggle()
|
||
syncBack()
|
||
}
|
||
|
||
// MARK: - Rest Timer
|
||
|
||
func startRestTimer() {
|
||
restTimerTotal = restTimerSeconds
|
||
restCountdown = restTimerSeconds
|
||
}
|
||
|
||
func stopRestTimer() {
|
||
restCountdown = 0
|
||
restTimerTotal = 0
|
||
}
|
||
|
||
private func syncBack() {
|
||
guard let idx = programs.firstIndex(where: { $0.id == activeProgramId }) else { return }
|
||
programs[idx].sections = activeSections
|
||
save()
|
||
}
|
||
|
||
// MARK: - Sample data
|
||
static func samplePrograms() -> [WorkoutProgram] {
|
||
func s(_ count: Int, reps: Int = 12) -> [ExerciseSet] {
|
||
(0..<count).map { _ in ExerciseSet(reps: reps) }
|
||
}
|
||
|
||
let warmUp = WorkoutSection(name: "Warm Up", exercises: [
|
||
Exercise(name: "Wide Pull-ups", sfSymbol: "figure.pull.up", colorIndex: 1, sets: s(3, reps: 10)),
|
||
Exercise(name: "Dips", sfSymbol: "figure.gymnastics", colorIndex: 0, sets: s(3, reps: 10)),
|
||
Exercise(name: "Ab Wheel Rollout", sfSymbol: "figure.roll", colorIndex: 2, sets: s(3, reps: 10)),
|
||
])
|
||
|
||
// lbs → kg conversion factor
|
||
let lb: Double = 0.453592
|
||
|
||
// ── Monday: Chest & Triceps ──────────────────────────────────────────
|
||
let mon = WorkoutProgram(name: "Chest & Triceps", emoji: "figure.strengthtraining.traditional", colorIndex: 0, sections: [
|
||
warmUp,
|
||
WorkoutSection(name: "Chest", exercises: [
|
||
// Incline first — 70 / 65 / 45 / 35 lbs × 12 reps (per-side dumbbell)
|
||
Exercise(name: "Incline Bench Press", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: [
|
||
ExerciseSet(weight: 70 * lb, reps: 12),
|
||
ExerciseSet(weight: 65 * lb, reps: 12),
|
||
ExerciseSet(weight: 45 * lb, reps: 12),
|
||
ExerciseSet(weight: 35 * lb, reps: 12),
|
||
], isDumbbell: true),
|
||
// Flat — 65 / 45 / 35 lbs × 12 reps
|
||
Exercise(name: "Bench Press", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: [
|
||
ExerciseSet(weight: 65 * lb, reps: 12),
|
||
ExerciseSet(weight: 45 * lb, reps: 12),
|
||
ExerciseSet(weight: 35 * lb, reps: 12),
|
||
]),
|
||
// Chest Flys — 35 / 25 / 25 lbs × 12 reps (per-side dumbbell)
|
||
Exercise(name: "Chest Flys", sfSymbol: "dumbbell.fill", colorIndex: 2, sets: [
|
||
ExerciseSet(weight: 35 * lb, reps: 12),
|
||
ExerciseSet(weight: 25 * lb, reps: 12),
|
||
ExerciseSet(weight: 25 * lb, reps: 12),
|
||
], isDumbbell: true),
|
||
]),
|
||
WorkoutSection(name: "Triceps", exercises: [
|
||
// Tricep Pushdown — 50 / 45 / 45 kg × 12 reps
|
||
Exercise(name: "Tricep Pushdown", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: [
|
||
ExerciseSet(weight: 50, reps: 12),
|
||
ExerciseSet(weight: 45, reps: 12),
|
||
ExerciseSet(weight: 45, reps: 12),
|
||
]),
|
||
// Overhead — 50 kg × 3 sets
|
||
Exercise(name: "Overhead Tricep Ext", sfSymbol: "dumbbell.fill", colorIndex: 3, sets: [
|
||
ExerciseSet(weight: 50, reps: 12),
|
||
ExerciseSet(weight: 50, reps: 12),
|
||
ExerciseSet(weight: 50, reps: 12),
|
||
]),
|
||
// Skull Crushers — 15 kg × 3 sets
|
||
Exercise(name: "Skull Crushers", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: [
|
||
ExerciseSet(weight: 15, reps: 12),
|
||
ExerciseSet(weight: 15, reps: 12),
|
||
ExerciseSet(weight: 15, reps: 12),
|
||
]),
|
||
]),
|
||
])
|
||
|
||
// ── Tuesday: Back & Biceps ───────────────────────────────────────────
|
||
let tue = WorkoutProgram(name: "Back & Biceps", emoji: "figure.strengthtraining.functional", colorIndex: 1, sections: [
|
||
warmUp,
|
||
WorkoutSection(name: "Back", exercises: [
|
||
Exercise(name: "Lat Pulldown", sfSymbol: "figure.pull.up", colorIndex: 1, sets: [
|
||
ExerciseSet(weight: 70, reps: 12),
|
||
ExerciseSet(weight: 60, reps: 12),
|
||
ExerciseSet(weight: 60, reps: 12),
|
||
]),
|
||
Exercise(name: "Cable Row", sfSymbol: "figure.rowing", colorIndex: 0, sets: [
|
||
ExerciseSet(weight: 60 * lb, reps: 12),
|
||
ExerciseSet(weight: 55 * lb, reps: 12),
|
||
ExerciseSet(weight: 55 * lb, reps: 12),
|
||
]),
|
||
Exercise(name: "Deadlift", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 2, sets: [
|
||
ExerciseSet(weight: 70 * lb, reps: 10),
|
||
ExerciseSet(weight: 70, reps: 10),
|
||
ExerciseSet(weight: 70, reps: 10),
|
||
], isDumbbell: true),
|
||
]),
|
||
WorkoutSection(name: "Biceps", exercises: [
|
||
Exercise(name: "Barbell Curls", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: [
|
||
ExerciseSet(weight: 10, reps: 12),
|
||
ExerciseSet(weight: 10, reps: 12),
|
||
ExerciseSet(weight: 10, reps: 12),
|
||
], isDumbbell: true),
|
||
Exercise(name: "Incline Hammer Curl", sfSymbol: "dumbbell.fill", colorIndex: 3, sets: [
|
||
ExerciseSet(weight: 30, reps: 12),
|
||
ExerciseSet(weight: 30, reps: 12),
|
||
ExerciseSet(weight: 30, reps: 12),
|
||
], isDumbbell: true),
|
||
Exercise(name: "Preacher Curl", sfSymbol: "dumbbell.fill", colorIndex: 1, sets: [
|
||
ExerciseSet(weight: 60, reps: 12),
|
||
ExerciseSet(weight: 60, reps: 12),
|
||
ExerciseSet(weight: 60, reps: 12),
|
||
]),
|
||
]),
|
||
])
|
||
|
||
// ── Wednesday: Shoulders, Triceps & Biceps ───────────────────────────
|
||
let wed = WorkoutProgram(name: "Shoulders & Arms", emoji: "figure.arms.open", colorIndex: 2, sections: [
|
||
warmUp,
|
||
WorkoutSection(name: "Shoulders", exercises: [
|
||
Exercise(name: "Reverse Fly", sfSymbol: "dumbbell.fill", colorIndex: 2, sets: s(3)),
|
||
Exercise(name: "Front Raises", sfSymbol: "figure.arms.open", colorIndex: 0, sets: s(3)),
|
||
Exercise(name: "Lateral Raises", sfSymbol: "figure.arms.open", colorIndex: 1, sets: s(3)),
|
||
Exercise(name: "Dumbbell Shoulder Press",sfSymbol: "dumbbell.fill", colorIndex: 3, sets: s(3)),
|
||
]),
|
||
WorkoutSection(name: "Triceps", exercises: [
|
||
Exercise(name: "Tricep Pushdown", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||
Exercise(name: "Overhead Tricep Ext",sfSymbol: "dumbbell.fill", colorIndex: 0, sets: s(3)),
|
||
]),
|
||
WorkoutSection(name: "Biceps", exercises: [
|
||
Exercise(name: "Barbell Curls", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||
Exercise(name: "Hammer Curls", sfSymbol: "dumbbell.fill", colorIndex: 3, sets: s(3)),
|
||
]),
|
||
])
|
||
|
||
// ── Thursday: Legs & Chest ───────────────────────────────────────────
|
||
let thu = WorkoutProgram(name: "Legs & Chest", emoji: "figure.run", colorIndex: 3, sections: [
|
||
warmUp,
|
||
WorkoutSection(name: "Legs", exercises: [
|
||
Exercise(name: "Hack Squat", sfSymbol: "figure.squat", colorIndex: 2, sets: s(3)),
|
||
Exercise(name: "Leg Extension", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||
Exercise(name: "Adductor Machine",sfSymbol: "figure.strengthtraining.functional", colorIndex: 3, sets: s(3)),
|
||
Exercise(name: "Lying Leg Curl", sfSymbol: "figure.strengthtraining.functional", colorIndex: 0, sets: s(3)),
|
||
Exercise(name: "Seated Leg Curl", sfSymbol: "figure.strengthtraining.functional", colorIndex: 2, sets: s(3)),
|
||
Exercise(name: "Leg Press", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||
]),
|
||
WorkoutSection(name: "Chest", exercises: [
|
||
Exercise(name: "Bench Press", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||
Exercise(name: "Chest Flys", sfSymbol: "dumbbell.fill", colorIndex: 2, sets: s(3)),
|
||
]),
|
||
])
|
||
|
||
// ── Friday: Full Body ────────────────────────────────────────────────
|
||
let fri = WorkoutProgram(name: "Full Body", emoji: "bolt.fill", colorIndex: 0, sections: [
|
||
warmUp,
|
||
WorkoutSection(name: "Upper Body", exercises: [
|
||
Exercise(name: "Bench Press", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||
Exercise(name: "Lat Pulldown", sfSymbol: "figure.pull.up", colorIndex: 1, sets: s(3)),
|
||
Exercise(name: "Dumbbell Shoulder Press", sfSymbol: "dumbbell.fill", colorIndex: 3, sets: s(3)),
|
||
Exercise(name: "Tricep Pushdown", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||
Exercise(name: "Barbell Curls", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||
]),
|
||
WorkoutSection(name: "Lower Body", exercises: [
|
||
Exercise(name: "Leg Press", sfSymbol: "figure.strengthtraining.functional", colorIndex: 2, sets: s(3)),
|
||
Exercise(name: "Hack Squat", sfSymbol: "figure.squat", colorIndex: 1, sets: s(3)),
|
||
Exercise(name: "Lying Leg Curl", sfSymbol: "figure.strengthtraining.functional", colorIndex: 3, sets: s(3)),
|
||
]),
|
||
])
|
||
|
||
// ── Saturday: Full Body ──────────────────────────────────────────────
|
||
let sat = WorkoutProgram(name: "Full Body", emoji: "figure.highintensity.intervaltraining", colorIndex: 1, sections: [
|
||
warmUp,
|
||
WorkoutSection(name: "Upper Body", exercises: [
|
||
Exercise(name: "Incline Bench Press",sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||
Exercise(name: "Cable Row", sfSymbol: "figure.rowing", colorIndex: 1, sets: s(3)),
|
||
Exercise(name: "Lateral Raises", sfSymbol: "figure.arms.open", colorIndex: 3, sets: s(3)),
|
||
Exercise(name: "Overhead Tricep Ext",sfSymbol: "dumbbell.fill", colorIndex: 0, sets: s(3)),
|
||
Exercise(name: "Hammer Curls", sfSymbol: "dumbbell.fill", colorIndex: 2, sets: s(3)),
|
||
]),
|
||
WorkoutSection(name: "Lower Body", exercises: [
|
||
Exercise(name: "Hack Squat", sfSymbol: "figure.squat", colorIndex: 2, sets: s(3)),
|
||
Exercise(name: "Leg Extension", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||
Exercise(name: "Seated Leg Curl", sfSymbol: "figure.strengthtraining.functional", colorIndex: 3, sets: s(3)),
|
||
]),
|
||
])
|
||
|
||
return [mon, tue, wed, thu, fri, sat]
|
||
}
|
||
}
|