Files
KisaniCal/KisaniCal/Views/WorkoutView.swift
kutesir 65c93769c5 workout: drop seal icon, confirm every destructive quick action (KC-52 follow-up 2)
checkmark.seal.fill (unused elsewhere, reads as a generic 'AI verified badge')
replaced with checkmark.circle.fill, the app's actual convention. All four
state-changing card actions (Finish Anyway, Check All Sets, Clear All Sets,
Didn't Train) now confirm via one PendingAction enum + confirmationDialog
before executing, each with a tailored message; destructive ones use the
.destructive role. The header menu's Clear All Sets (any completion level)
also now confirms instead of executing instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00

2118 lines
100 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import AudioToolbox
import Charts
// MARK: - Rest Timer Banner
private struct RestTimerBanner: View {
let countdown: Int
let total: Int
let onSkip: () -> Void
@Environment(\.colorScheme) private var cs
private var progress: Double { total > 0 ? Double(countdown) / Double(total) : 0 }
private var timeLabel: String {
countdown >= 60
? String(format: "%d:%02d", countdown / 60, countdown % 60)
: "\(countdown)s"
}
var body: some View {
HStack(spacing: 14) {
ZStack {
Circle()
.stroke(AppColors.accent.opacity(0.18), lineWidth: 3)
Circle()
.trim(from: 0, to: progress)
.stroke(AppColors.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: progress)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 1) {
Text("REST").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
Text(timeLabel).font(AppFonts.mono(22, weight: .bold)).foregroundColor(AppColors.accent)
}
Spacer()
Button(action: onSkip) {
Text("Skip")
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.accent)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(AppColors.accentSoft)
.clipShape(Capsule())
}
.buttonStyle(PressButtonStyle(scale: 0.94))
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18))
.overlay(RoundedRectangle(cornerRadius: 18).stroke(AppColors.accent.opacity(0.2), lineWidth: 1))
.padding(.horizontal, 16)
}
}
// MARK: - Workout View
struct WorkoutView: View {
@Environment(\.colorScheme) var cs
@EnvironmentObject var vm: WorkoutViewModel
@State private var showManage = false
@State private var showAddSection = false
@State private var showHistory = false
@State private var showStats = false
@State private var showAnalytics = false
@State private var isReordering = false
@State private var trainAnyway = false // override the rest-day state for today
@State private var showClearAllConfirm = false
@ObservedObject private var tm = TutorialManager.shared
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private var headerDate: String {
let f = DateFormatter(); f.dateFormat = "EEE, MMM d"
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 {
Color.black.opacity(0.001)
.ignoresSafeArea()
.onTapGesture {
withAnimation(KisaniSpring.snappy) { isReordering = false }
}
}
List {
// Header
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
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))
}
Text(headerDate)
.font(AppFonts.mono(10))
.foregroundColor(AppColors.text3(cs))
}
Spacer()
IButton(icon: "chart.line.uptrend.xyaxis") { showAnalytics = true }
IButton(icon: "chart.bar.xaxis") { showStats = true }
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
Menu {
Button(role: .destructive) { showClearAllConfirm = true } label: {
Label("Clear All Sets", systemImage: "circle")
}
Divider()
Button { showManage = true } label: { Label("Manage Workouts", systemImage: "slider.horizontal.3") }
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 13, weight: .medium))
.foregroundColor(AppColors.text2(cs))
.frame(width: 34, height: 34)
.background(AppColors.surface2(cs))
.clipShape(Circle())
.overlay(Circle().stroke(AppColors.border(cs), lineWidth: 1))
}
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.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 + Completion
DotProgressCard(
progress: vm.progress, done: vm.doneSets, total: vm.totalSets,
isDoneToday: vm.isWorkoutComplete(on: Date()),
isMissedToday: vm.isWorkoutMissed(on: Date()),
onFinish: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutDone(on: Date()) } },
onMarkAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } },
onClearAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } },
onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } },
onUndo: {
withAnimation(KisaniSpring.snappy) {
if vm.isWorkoutMissed(on: Date()) { vm.unmarkWorkoutMissed(on: Date()) }
else { vm.unmarkWorkoutDone(on: Date()) }
}
}
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
.moveDisabled(true)
// Streak
StreakBannerView(vm: vm)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14))
.moveDisabled(true)
// Workout Sections
ForEach($vm.activeSections) { $section in
Section {
if section.isExpanded {
if section.exercises.isEmpty {
Text("No exercises — tap + to add")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14))
.moveDisabled(true)
} else {
ForEach($section.exercises) { $ex in
ExerciseCard(exercise: $ex, sectionId: section.id)
.environmentObject(vm)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
.onLongPressGesture(minimumDuration: 0.4) {
withAnimation(KisaniSpring.snappy) { isReordering = true }
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: {
Label("Delete Exercise", systemImage: "trash")
}
}
}
.onMove { from, to in vm.moveExercise(sectionId: section.id, from: from, to: to) }
}
// Add exercise to this section
Button {
vm.addExerciseTargetSectionId = section.id
vm.showAddExercise = true
} label: {
HStack(spacing: 6) {
Image(systemName: "plus").font(.system(size: 12))
Text("Add Exercise").font(AppFonts.mono(10.5, weight: .semibold)).textCase(.uppercase)
}
.foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
.foregroundColor(AppColors.borderHi(cs))
)
}
.buttonStyle(.plain)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 2, leading: 14, bottom: 10, trailing: 14))
.moveDisabled(true)
}
} header: {
WorkoutSectionHeader(section: $section)
.environmentObject(vm)
}
}
// Add Section
Button { showAddSection = true } label: {
HStack(spacing: 8) {
Image(systemName: "folder.badge.plus").font(.system(size: 14)).foregroundColor(AppColors.accent)
Text("Add Section").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent)
}
.frame(maxWidth: .infinity).padding(12)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.medium)
.strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4]))
.foregroundColor(AppColors.accent.opacity(0.4))
)
}
.buttonStyle(.plain)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 14, bottom: 100, trailing: 14))
.moveDisabled(true)
} // end non-rest content
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, .constant(isReordering ? .active : .inactive))
.scrollDismissesKeyboard(.immediately)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil
)
}
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.accent)
}
}
if tm.workoutIsActive {
VStack {
Spacer()
InViewTutorialCard(
tips: tm.workoutTips,
step: $tm.workoutStep,
onAdvance: { tm.advanceWorkout() },
onSkip: { tm.skipWorkout() }
)
.padding(.bottom, 84)
}
.transition(.opacity)
.zIndex(10)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
.overlay(alignment: .top) {
if isReordering {
Button {
withAnimation(KisaniSpring.snappy) { isReordering = false }
} label: {
HStack(spacing: 6) {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
Text("Done Reordering")
.font(AppFonts.sans(13, weight: .semibold))
}
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 9)
.background(AppColors.accent)
.clipShape(Capsule())
.shadow(color: AppColors.accent.opacity(0.35), radius: 8, y: 4)
}
.buttonStyle(PressButtonStyle(scale: 0.96))
.padding(.top, 12)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.sheet(isPresented: $vm.showAddExercise) {
if let sid = vm.addExerciseTargetSectionId {
ExercisePickerSheet(sectionId: sid)
.environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
.sheet(isPresented: $showManage) {
ManageWorkoutsSheet().environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showHistory) {
WorkoutHistoryView().environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showAnalytics) {
AnalyticsView()
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showStats) {
WorkoutStatsView().environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.confirmationDialog(
"Clear all logged sets?",
isPresented: $showClearAllConfirm,
titleVisibility: .visible
) {
Button("Clear All", role: .destructive) {
withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This un-checks every set in today's workout. It won't undo an already-finished workout.")
}
.sheet(isPresented: $showAddSection) {
AddSectionSheet().environmentObject(vm)
.presentationDetents([.height(240)])
.presentationDragIndicator(.visible)
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if vm.restCountdown > 0 {
RestTimerBanner(
countdown: vm.restCountdown,
total: vm.restTimerTotal,
onSkip: { vm.stopRestTimer() }
)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 70)
}
}
.onReceive(restTick) { _ in
guard vm.restCountdown > 0 else { return }
vm.restCountdown -= 1
if vm.restCountdown == 0 {
UINotificationFeedbackGenerator().notificationOccurred(.success)
AudioServicesPlaySystemSound(1007)
}
}
.animation(KisaniSpring.snappy, value: vm.restCountdown > 0)
}
}
// MARK: - Program Icon
// Renders SF Symbol names (contain ".") or legacy emoji strings.
struct ProgramIcon: View {
let name: String
var size: CGFloat = 18
var color: Color
private var isSFSymbol: Bool { name.contains(".") }
var body: some View {
if isSFSymbol {
Image(systemName: name)
.font(.system(size: size, weight: .medium))
.foregroundColor(color)
} else {
Text(name).font(.system(size: size))
}
}
}
// MARK: - Section Header
struct WorkoutSectionHeader: View {
@Binding var section: WorkoutSection
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@State private var showRename = false
@State private var showDeleteConfirm = false
var body: some View {
HStack(spacing: 8) {
Button { withAnimation(KisaniSpring.micro) { vm.toggleSectionExpanded(section.id) } } label: {
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .bold))
.foregroundColor(AppColors.text3(cs))
.rotationEffect(.degrees(section.isExpanded ? 90 : 0))
.frame(width: 18)
}
.buttonStyle(.plain)
Text(section.name.uppercased())
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.text2(cs))
.textCase(nil)
Text("\(section.exercises.count)")
.font(AppFonts.mono(8.5, weight: .bold))
.foregroundColor(AppColors.text3(cs))
.padding(.horizontal, 5).padding(.vertical, 2)
.background(AppColors.surface2(cs))
.clipShape(Capsule())
Spacer()
Menu {
Button { showRename = true } label: { Label("Rename", systemImage: "pencil") }
Button { withAnimation { vm.moveSectionUp(section.id) } } label: { Label("Move Up", systemImage: "chevron.up") }
Button { withAnimation { vm.moveSectionDown(section.id) } } label: { Label("Move Down", systemImage: "chevron.down") }
Divider()
Button(role: .destructive) { showDeleteConfirm = true } label: { Label("Delete Section", systemImage: "trash") }
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 11))
.foregroundColor(AppColors.text3(cs))
.frame(width: 28, height: 28)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 14).padding(.vertical, 6)
.background(AppColors.background(cs))
.confirmationDialog("Delete \"\(section.name)\"?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
Button("Delete Section", role: .destructive) { withAnimation { vm.deleteSection(section.id) } }
}
.sheet(isPresented: $showRename) {
RenameSectionSheet(section: section).environmentObject(vm)
.presentationDetents([.height(220)])
.presentationDragIndicator(.visible)
}
}
}
// MARK: - Rename Section Sheet
struct RenameSectionSheet: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
let section: WorkoutSection
@State private var name: String
@FocusState private var focused: Bool
init(section: WorkoutSection) {
self.section = section
_name = State(initialValue: section.name)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Rename Section").font(AppFonts.sans(16, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
TextField("Section name", text: $name)
.font(AppFonts.sans(15)).foregroundColor(AppColors.text(cs))
.focused($focused).padding(14)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(focused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
.padding(.horizontal, 20).padding(.top, 18)
Spacer()
Button {
guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return }
vm.renameSection(section.id, name: name.trimmingCharacters(in: .whitespaces))
dismiss()
} label: {
Text("Save").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white)
.frame(maxWidth: .infinity).padding(14)
.background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
}
.buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 20).padding(.bottom, 20)
}
.background(AppColors.background(cs).ignoresSafeArea())
.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { focused = true } }
}
}
// MARK: - Add Section Sheet
struct AddSectionSheet: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
@State private var name = ""
@FocusState private var focused: Bool
private let presets = ["Warm Up", "Main Lifts", "Accessories", "Cool Down", "Cardio", "Supersets"]
var body: some View {
VStack(spacing: 0) {
HStack {
Text("New Section").font(AppFonts.sans(16, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
VStack(alignment: .leading, spacing: 12) {
TextField("e.g. Warm Up, Main Lifts…", text: $name)
.font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
.focused($focused).padding(13)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(focused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(presets, id: \.self) { preset in
Button { name = preset } label: {
Text(preset)
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(name == preset ? AppColors.accent : AppColors.text3(cs))
.padding(.horizontal, 10).padding(.vertical, 5)
.background(name == preset ? AppColors.accentSoft : AppColors.surface2(cs))
.clipShape(Capsule())
.overlay(Capsule().stroke(name == preset ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
}
.buttonStyle(.plain)
}
}
}
}
.padding(.horizontal, 20).padding(.top, 14)
Spacer()
Button {
guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return }
vm.addSection(name: name.trimmingCharacters(in: .whitespaces))
dismiss()
} label: {
Text("Add Section").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white)
.frame(maxWidth: .infinity).padding(14)
.background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
}
.buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 20).padding(.bottom, 20)
}
.background(AppColors.background(cs).ignoresSafeArea())
.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { focused = true } }
}
}
// MARK: - Dot Progress Card
struct DotProgressCard: View {
@Environment(\.colorScheme) private var cs
let progress: Double; let done: Int; let total: Int
let isDoneToday: Bool
let isMissedToday: Bool
/// Capture today as done regardless of how many sets are checked the app's
/// own set-ticking is a nice-to-have log, not the source of truth for whether
/// a workout happened (Health/Watch already knows that independently).
let onFinish: () -> Void
let onMarkAllSets: () -> Void
let onClearAllSets: () -> Void
let onNotCompleted: () -> Void
let onUndo: () -> Void
/// Every quick action that changes today's state is confirmed first a
/// stray tap here shouldn't silently rewrite the day.
private enum PendingAction: Identifiable {
case finish, checkAll, clearAll, notCompleted
var id: Self { self }
var confirmLabel: String {
switch self {
case .finish: return "Finish Anyway"
case .checkAll: return "Check All"
case .clearAll: return "Clear All"
case .notCompleted: return "Mark Not Completed"
}
}
var isDestructive: Bool { self == .clearAll || self == .notCompleted }
}
@State private var pendingAction: PendingAction?
var body: some View {
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(statusTitle)
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Text(statusSubtitle)
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
Spacer()
statusBadge
}
if isDoneToday || isMissedToday {
undoRow
} else {
VStack(alignment: .leading, spacing: 6) {
Text("PROGRESS")
.font(AppFonts.mono(8, weight: .bold))
.foregroundColor(AppColors.text3(cs))
DotGridProgress(progress: total > 0 ? progress : 0, cs: cs)
}
actionRow
}
}
.padding(14)
.cardStyle(borderColor: isDoneToday ? AppColors.green.opacity(0.3) : nil)
.confirmationDialog(
confirmTitle,
isPresented: Binding(get: { pendingAction != nil }, set: { if !$0 { pendingAction = nil } }),
titleVisibility: .visible,
presenting: pendingAction
) { action in
Button(action.confirmLabel, role: action.isDestructive ? .destructive : nil) {
perform(action)
}
Button("Cancel", role: .cancel) {}
} message: { action in
Text(confirmMessage(action))
}
}
private var confirmTitle: String {
switch pendingAction {
case .finish: return "Finish with \(done) of \(total) sets logged?"
case .checkAll: return "Check every set as done?"
case .clearAll: return "Clear all logged sets?"
case .notCompleted: return "Mark today as not completed?"
case nil: return ""
}
}
private func confirmMessage(_ action: PendingAction) -> String {
switch action {
case .finish:
return total > 0
? "You've logged \(Int(progress * 100))% of sets. This still counts as today's workout."
: "No sets logged yet. This still counts as today's workout."
case .checkAll:
return "This ticks all \(total) sets as done — useful if you trained but didn't log along the way."
case .clearAll:
return "This un-checks all \(total) sets. It won't undo today's logged workout."
case .notCompleted:
return isDoneToday ? "This replaces today's logged workout with \"not completed.\"" : "You can undo this afterward."
}
}
private func perform(_ action: PendingAction) {
switch action {
case .finish: onFinish()
case .checkAll: onMarkAllSets()
case .clearAll: onClearAllSets()
case .notCompleted: onNotCompleted()
}
}
private var statusTitle: String {
if isDoneToday { return "Workout logged" }
if isMissedToday { return "Marked as not completed" }
return "\(done) of \(total) sets done"
}
private var statusSubtitle: String {
if isDoneToday { return "Counted toward today · \(done)/\(total) sets logged" }
if isMissedToday { return "Doesn't count toward today" }
if total == 0 { return "Add exercises to get started" }
if done == total { return "All sets done" }
return "\(total - done) sets remaining"
}
@ViewBuilder private var statusBadge: some View {
if isDoneToday {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18)).foregroundColor(AppColors.green)
} else if isMissedToday {
Image(systemName: "moon.zzz.fill")
.font(.system(size: 16)).foregroundColor(AppColors.text3(cs))
} else {
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
.font(AppFonts.mono(13, weight: .bold))
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
}
}
/// Primary: finish the workout as-is (honest capture, not gated on 100% sets).
/// Secondary: the mechanical "tick every box" action (toggles to a clear once
/// all are checked), and the explicit miss. Every action here that changes
/// today's state is confirmed first see `pendingAction`.
private var actionRow: some View {
VStack(spacing: 8) {
Button {
if done == total && total > 0 { onFinish() } else { pendingAction = .finish }
} label: {
Label(done == total && total > 0 ? "Finish Workout" : "Finish Anyway",
systemImage: "checkmark.circle.fill")
.font(AppFonts.sans(13, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
}
.buttonStyle(QuietAccentButtonStyle())
HStack(spacing: 8) {
if done == total && total > 0 {
Button { pendingAction = .clearAll } label: {
Label("Clear all sets", systemImage: "circle")
.font(AppFonts.sans(11, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.foregroundColor(AppColors.text2(cs))
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 9))
.buttonStyle(PressButtonStyle(scale: 0.97))
} else {
Button { pendingAction = .checkAll } label: {
Label("Check all sets", systemImage: "checkmark.circle")
.font(AppFonts.sans(11, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.foregroundColor(AppColors.text2(cs))
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 9))
.buttonStyle(PressButtonStyle(scale: 0.97))
}
Button { pendingAction = .notCompleted } label: {
Label("Didn't train", systemImage: "moon.zzz")
.font(AppFonts.sans(11, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.foregroundColor(AppColors.text3(cs))
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 9))
.buttonStyle(PressButtonStyle(scale: 0.97))
}
}
}
private var undoRow: some View {
HStack {
Spacer()
Button(action: onUndo) {
Label("Undo", systemImage: "arrow.uturn.backward")
.font(AppFonts.sans(11, weight: .semibold))
}
.foregroundColor(AppColors.accent)
}
}
}
// MARK: - Workout Stats
struct WorkoutStatsView: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
@State private var period: StatPeriod = .week
@State private var metric: Metric = .volume
enum Metric: String, CaseIterable, Identifiable {
case volume = "Volume", sets = "Sets", workouts = "Workouts"
var id: String { rawValue }
}
private var current: WorkoutStat { vm.stat(period, offset: 0) }
private var previous: WorkoutStat { vm.stat(period, offset: -1) }
private var buckets: [StatBucket] {
switch period {
case .day: return vm.statBuckets(.day, count: 14)
case .week: return vm.statBuckets(.week, count: 8)
case .month: return vm.statBuckets(.month, count: 6)
}
}
private func value(_ s: WorkoutStat) -> Double {
switch metric { case .volume: return s.volume; case .sets: return Double(s.sets); case .workouts: return Double(s.workouts) }
}
private static let num: NumberFormatter = {
let f = NumberFormatter(); f.numberStyle = .decimal; f.maximumFractionDigits = 0; return f
}()
private func volStr(_ v: Double) -> String { (Self.num.string(from: NSNumber(value: v)) ?? "0") + " kg" }
private func delta(_ cur: Double, _ prev: Double) -> (String, Bool)? {
guard prev > 0 else { return cur > 0 ? ("New", true) : nil }
let pct = (cur - prev) / prev * 100
if abs(pct) < 1 { return ("±0%", true) }
return (String(format: "%+.0f%%", pct), pct >= 0)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Workout Stats").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12)
Picker("", selection: $period) {
ForEach(StatPeriod.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented).padding(.horizontal, 20).padding(.bottom, 12)
Divider().background(AppColors.border(cs))
if vm.history.isEmpty {
VStack(spacing: 8) {
Image(systemName: "chart.bar.xaxis").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
Text("No data yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Text("Complete workouts and your stats appear here.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
} else {
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
// Summary cards (this period vs last)
HStack(spacing: 10) {
statCard("Workouts", "\(current.workouts)", delta(Double(current.workouts), Double(previous.workouts)))
statCard("Sets", "\(current.sets)", delta(Double(current.sets), Double(previous.sets)))
}
statCard("Volume", volStr(current.volume), delta(current.volume, previous.volume), wide: true)
// Metric chooser
Picker("", selection: $metric) {
ForEach(Metric.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
// Trend chart
VStack(alignment: .leading, spacing: 8) {
Text("\(metric.rawValue.uppercased()) · LAST \(buckets.count) \(period.rawValue.uppercased())S")
.font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
Chart(buckets) { b in
BarMark(
x: .value("Period", b.label),
y: .value(metric.rawValue, value(b.stat))
)
.foregroundStyle(AppColors.accent.gradient)
.cornerRadius(4)
}
.frame(height: 180)
.chartYAxis { AxisMarks(position: .leading) }
}
.padding(14).cardStyle()
}
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
}
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
@ViewBuilder
private func statCard(_ title: String, _ value: String, _ delta: (String, Bool)?, wide: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title.uppercased()).font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(value).font(AppFonts.mono(wide ? 24 : 20, weight: .bold)).foregroundColor(AppColors.text(cs))
if let d = delta {
HStack(spacing: 2) {
Image(systemName: d.1 ? "arrow.up.right" : "arrow.down.right").font(.system(size: 9, weight: .bold))
Text(d.0).font(AppFonts.mono(10, weight: .bold))
}
.foregroundColor(d.1 ? AppColors.green : AppColors.red)
}
Spacer()
}
Text("vs last \(period.rawValue.lowercased())").font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14).cardStyle()
}
}
// MARK: - Workout History
struct WorkoutHistoryView: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
private let parser: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
}()
private let pretty: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE, MMM d"; return f
}()
private func label(_ key: String) -> String {
guard let d = parser.date(from: key) else { return key }
if Calendar.current.isDateInToday(d) { return "Today" }
if Calendar.current.isDateInYesterday(d) { return "Yesterday" }
return pretty.string(from: d)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Workout History").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
if vm.historySorted.isEmpty {
VStack(spacing: 8) {
Image(systemName: "clock.arrow.circlepath").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
Text("No history yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Text("Completed workouts are saved here each day.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
} else {
ScrollView(showsIndicators: false) {
VStack(spacing: 12) {
ForEach(vm.historySorted) { day in
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
ProgramIcon(name: day.programEmoji, size: 14, color: AppColors.accent)
Text(day.programName).font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text(cs))
Spacer()
Text(label(day.date)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
Text("\(day.doneSets)/\(day.totalSets) sets")
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.green)
ForEach(day.exercises) { ex in
HStack(spacing: 8) {
Image(systemName: ex.sfSymbol).font(.system(size: 11)).foregroundColor(AppColors.text3(cs)).frame(width: 18)
Text(ex.name).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
Spacer()
Text("\(ex.doneSets)/\(ex.sets.count)").font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
}
}
.padding(14).cardStyle()
}
}
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
}
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
}
// 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
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
// Swipeable: this week this year this-week-vs-last-week.
struct StreakBannerView: View {
@Environment(\.colorScheme) private var cs
@ObservedObject var vm: WorkoutViewModel
@State private var page = 0
var body: some View {
VStack(spacing: 8) {
TabView(selection: $page) {
weekPage.tag(0)
yearPage.tag(1)
comparePage.tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(height: 50)
// Page dots
HStack(spacing: 5) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i == page ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 5, height: 5)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 13)
.cardStyle()
}
// Page 1 this week (per-day ticks)
private var weekPage: some View {
HStack(spacing: 0) {
statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
Spacer()
HStack(spacing: 3) {
ForEach(Array(vm.currentWeekDays.enumerated()), id: \.offset) { _, done in
RoundedRectangle(cornerRadius: 1.5)
.fill(done ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 18, height: 3)
}
}
}
}
// Page 2 this year (month ticks) + all-time total
private var yearPage: some View {
HStack(spacing: 0) {
statBlock(value: "\(vm.workoutsThisYear)", label: "this year")
Spacer()
VStack(alignment: .trailing, spacing: 5) {
HStack(spacing: 2) {
ForEach(Array(vm.currentYearMonths.enumerated()), id: \.offset) { _, active in
RoundedRectangle(cornerRadius: 1)
.fill(active ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 9, height: 3)
}
}
Text("\(vm.streakDays) all-time")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
}
}
// Page 3 this week vs last week
private var comparePage: some View {
let delta = vm.workoutsThisWeek - vm.workoutsLastWeek
let deltaColor = delta > 0 ? AppColors.green : (delta < 0 ? AppColors.accent : AppColors.text3(cs))
return HStack(spacing: 0) {
statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
Spacer()
VStack(alignment: .trailing, spacing: 3) {
HStack(spacing: 4) {
Image(systemName: delta > 0 ? "arrow.up.right" : (delta < 0 ? "arrow.down.right" : "equal"))
.font(.system(size: 10, weight: .bold))
Text(delta == 0
? "same as last week"
: "\(abs(delta)) \(delta > 0 ? "more" : "fewer") than last week")
.font(AppFonts.mono(10, weight: .semibold))
}
.foregroundColor(deltaColor)
Text("last week: \(vm.workoutsLastWeek)")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
}
}
private func statBlock(value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(value)
.font(AppFonts.mono(28, weight: .bold))
.foregroundColor(AppColors.text(cs))
Text(label)
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
}
}
}
// MARK: - Exercise Card
struct ExerciseCard: View {
@Binding var exercise: Exercise
let sectionId: UUID
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
var borderColor: Color { exercise.isPartialDone ? AppColors.accent : AppColors.border(cs) }
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
Image(systemName: exercise.sfSymbol).font(.system(size: 15, weight: .medium))
.foregroundColor(exercise.colorIndex.iconColor)
.frame(width: 32, height: 32).background(exercise.colorIndex.iconBg).cornerRadius(9)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(exercise.name).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
// ×2 dumbbell pill inline with title, compact
if exercise.isDumbbell {
Text("×2")
.font(AppFonts.mono(7.5, weight: .bold))
.foregroundColor(AppColors.accent)
.padding(.horizontal, 4).padding(.vertical, 2)
.background(AppColors.accentSoft)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
}
Text("\(exercise.totalSets) sets · \(exercise.doneSets) done")
.font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
Spacer()
// Toggle button visible but not loud
Button {
withAnimation(KisaniSpring.micro) {
vm.toggleDumbbell(sectionId: sectionId, exerciseId: exercise.id)
}
} label: {
Image(systemName: "dumbbell.fill")
.font(.system(size: 10))
.foregroundColor(exercise.isDumbbell ? AppColors.accent : AppColors.text3(cs).opacity(0.5))
.padding(6)
.background(exercise.isDumbbell ? AppColors.accentSoft : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(PressButtonStyle(scale: 0.9))
Image(systemName: "chevron.right").font(.system(size: 10, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
.rotationEffect(.degrees(exercise.isExpanded ? 90 : 0))
.animation(KisaniSpring.micro, value: exercise.isExpanded)
.padding(.leading, 10)
}
.padding(11).contentShape(Rectangle())
.onTapGesture { withAnimation(KisaniSpring.micro) { vm.toggleExpanded(sectionId: sectionId, exerciseId: exercise.id) } }
if exercise.isExpanded {
Divider().background(AppColors.border(cs))
SetsArea(exercise: $exercise, sectionId: sectionId).environmentObject(vm)
}
}
.cardStyle(borderColor: borderColor)
}
}
private let bodyweightExercises: Set<String> = [
"Wide Pull-ups", "Pull-ups", "Dips", "Tricep Dips", "Push-ups",
"Ab Wheel Rollout", "Hanging Leg Raise", "Chest Dips"
]
// MARK: - Sets Area
struct SetsArea: View {
@Binding var exercise: Exercise
let sectionId: UUID
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) private var cs
private var isBodyweight: Bool { bodyweightExercises.contains(exercise.name) }
private var isDumbbell: Bool { exercise.isDumbbell }
private var weightHeader: String {
if isBodyweight { return "Bodyweight" }
return isDumbbell ? "Each Side" : "Weight"
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 4) {
Text("#").frame(width: 28, alignment: .leading)
Text(weightHeader.lowercased()).frame(maxWidth: .infinity)
Text("reps").frame(maxWidth: .infinity)
Text(isDumbbell ? "total" : "").frame(maxWidth: .infinity)
Image(systemName: "checkmark")
.font(.system(size: 8, weight: .bold))
.frame(width: 40)
}
.font(AppFonts.mono(8, weight: .semibold)).foregroundColor(AppColors.text3(cs))
.padding(.horizontal, 13).padding(.top, 8).padding(.bottom, 4)
ForEach($exercise.sets) { $set in
let idx = exercise.sets.firstIndex(where: { $0.id == set.id }) ?? 0
SetRowView(set: $set, number: idx + 1, sectionId: sectionId, exerciseId: exercise.id,
isBodyweight: isBodyweight, isDumbbell: isDumbbell)
.environmentObject(vm)
if idx < exercise.sets.count - 1 {
Divider().background(AppColors.border(cs)).padding(.horizontal, 13)
}
}
Button { vm.addSet(sectionId: sectionId, to: exercise.id) } label: {
HStack(spacing: 6) {
Image(systemName: "plus").font(.system(size: 14))
Text("Add Set").font(AppFonts.mono(11, weight: .semibold)).textCase(.uppercase)
}
.foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity).padding(.vertical, 7)
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3])).foregroundColor(AppColors.borderHi(cs)))
}
.buttonStyle(.plain).padding(.horizontal, 13).padding(.top, 7).padding(.bottom, 10)
}
}
}
// MARK: - Set Row
struct SetRowView: View {
@Binding var set: ExerciseSet
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) private var cs
@AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 80
@AppStorage("weightUnit") private var globalUnit: Int = 0 // 0 = kg, 1 = lbs
let number: Int; let sectionId: UUID; let exerciseId: UUID
var isBodyweight: Bool = false
var isDumbbell: Bool = false
// String state fixes backspace (value:format: swallows delete events)
@State private var weightStr = ""
@State private var repsStr = ""
// Local unit for this row, defaults to global but user can override
@State private var rowUnit: Int = 0
private static let kgToLbs = 2.20462
private static let lbsToKg = 1.0 / 2.20462
private func toDisplay(_ kg: Double) -> String {
let val = rowUnit == 1 ? kg * Self.kgToLbs : kg
guard val > 0 else { return "" }
return val.truncatingRemainder(dividingBy: 1) == 0
? "\(Int(val))"
: String(format: "%.1f", val)
}
private func toKg(_ str: String) -> Double {
guard let val = Double(str) else { return 0 }
return rowUnit == 1 ? val * Self.lbsToKg : val
}
private func syncDisplay() {
let base = (isBodyweight && set.weight == 0) ? bodyWeightKg : set.weight
weightStr = toDisplay(base)
}
var body: some View {
HStack(spacing: 4) {
Text("\(number)")
.font(AppFonts.mono(10.5, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
.frame(width: 28, alignment: .leading)
// Weight + unit toggle
HStack(spacing: 0) {
TextField(isBodyweight ? toDisplay(bodyWeightKg) : "0", text: $weightStr)
.font(AppFonts.mono(12)).foregroundColor(AppColors.text(cs))
.multilineTextAlignment(.center)
.keyboardType(.decimalPad)
.onChange(of: weightStr) { val in
let clean = String(val.filter { $0.isNumber || $0 == "." }.prefix(6))
if clean != val { weightStr = clean }
set.weight = toKg(clean)
}
// Tappable unit pill switches this row's unit and reconverts display
Button {
let kg = set.weight
withAnimation(KisaniSpring.micro) { rowUnit = rowUnit == 0 ? 1 : 0 }
weightStr = toDisplay(kg)
} label: {
Text(rowUnit == 0 ? "kg" : "lbs")
.font(AppFonts.mono(8, weight: .bold))
.foregroundColor(rowUnit == 1 ? AppColors.accent : AppColors.text3(cs))
.padding(.horizontal, 5).padding(.vertical, 3)
.background(rowUnit == 1 ? AppColors.accentSoft : AppColors.surface3(cs))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
.buttonStyle(PressButtonStyle(scale: 0.92))
.padding(.trailing, 4)
}
.padding(.vertical, 4).padding(.leading, 6)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(
isBodyweight ? AppColors.blue.opacity(0.25) : AppColors.border(cs), lineWidth: 1))
.frame(maxWidth: .infinity)
// Reps field
TextField("0", text: $repsStr)
.font(AppFonts.mono(12)).foregroundColor(AppColors.text(cs))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.onChange(of: repsStr) { val in
let clean = String(val.filter { $0.isNumber }.prefix(3))
if clean != val { repsStr = clean }
set.reps = Int(clean) ?? 0
}
.padding(.vertical, 5).padding(.horizontal, 4)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1))
.frame(maxWidth: .infinity)
// Dumbbell total or empty spacer
Group {
if isDumbbell && set.weight > 0 {
let totalKg = set.weight * 2
let displayTotal = rowUnit == 1
? totalKg * Self.kgToLbs
: totalKg
let unit = rowUnit == 1 ? "lbs" : "kg"
let totalStr = displayTotal.truncatingRemainder(dividingBy: 1) == 0
? "\(Int(displayTotal))\(unit)"
: String(format: "%.1f\(unit)", displayTotal)
VStack(spacing: 1) {
Text("= \(totalStr)")
.font(AppFonts.mono(9, weight: .bold))
.foregroundColor(AppColors.accent)
Text("total")
.font(AppFonts.mono(7))
.foregroundColor(AppColors.text3(cs))
}
.frame(maxWidth: .infinity)
} else {
Spacer().frame(maxWidth: .infinity)
}
}
Button { vm.toggleSet(sectionId: sectionId, exerciseId: exerciseId, setId: set.id) } label: {
Image(systemName: set.isDone ? "checkmark" : "circle")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(set.isDone ? AppColors.green : AppColors.text3(cs))
.frame(width: 28, height: 28)
.background(set.isDone ? AppColors.greenSoft : AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 7))
.overlay(RoundedRectangle(cornerRadius: 7).stroke(
set.isDone ? AppColors.green : AppColors.border(cs), lineWidth: 1))
.scaleEffect(set.isDone ? 1.0 : 0.92)
.animation(KisaniSpring.bounce, value: set.isDone)
}
.buttonStyle(PressButtonStyle(scale: 0.88)).frame(width: 40, alignment: .center)
}
.padding(.horizontal, 13).padding(.vertical, 4)
.onAppear {
rowUnit = globalUnit
if isBodyweight && set.weight == 0 { set.weight = bodyWeightKg }
syncDisplay()
repsStr = set.reps > 0 ? "\(set.reps)" : ""
}
// When the user changes the global default in Settings, reformat all open rows
.onChange(of: globalUnit) { newUnit in
withAnimation(KisaniSpring.micro) { rowUnit = newUnit }
weightStr = toDisplay(set.weight)
}
}
}
// MARK: - Manage Workouts Sheet
struct ManageWorkoutsSheet: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
@State private var showAddProgram = false
private let weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
private let weekdayNumbers = [2, 3, 4, 5, 6, 7, 1]
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Manage Workouts").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
Color.clear.frame(height: 0).trackScrollForTabBar()
VStack(alignment: .leading, spacing: 8) {
Text("PROGRAMS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
ForEach(vm.programs) { prog in
ProgramRow(program: prog)
if prog.id != vm.programs.last?.id {
Divider().background(AppColors.border(cs)).padding(.leading, 52)
}
}
}
.cardStyle()
Button { showAddProgram = true } label: {
HStack(spacing: 8) {
Image(systemName: "plus.circle.fill").font(.system(size: 16)).foregroundColor(AppColors.accent)
Text("New Program").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent)
}
.frame(maxWidth: .infinity).padding(12)
.overlay(RoundedRectangle(cornerRadius: AppRadius.medium).strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4])).foregroundColor(AppColors.accent.opacity(0.4)))
}
.buttonStyle(.plain)
}
VStack(alignment: .leading, spacing: 8) {
Text("WEEKLY SCHEDULE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
Text("Tap a day to assign a workout program")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
HStack(spacing: 6) {
ForEach(weekdays.indices, id: \.self) { i in
DayScheduleCell(day: weekdays[i], weekdayNum: weekdayNumbers[i]).environmentObject(vm)
}
}
}
}
.padding(.horizontal, 20).padding(.top, 18).padding(.bottom, 30)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
.sheet(isPresented: $showAddProgram) {
AddProgramSheet().environmentObject(vm)
.presentationDetents([.height(420)])
.presentationDragIndicator(.visible)
}
}
}
// MARK: - Program Row
private struct ProgramRow: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
let program: WorkoutProgram
@State private var showConfirmDelete = false
@State private var showRename = false
@State private var showDetail = false
var isActive: Bool { vm.activeProgramId == program.id }
var subtitle: String {
let s = program.sections.count
let e = program.totalExercises
return "\(s) section\(s == 1 ? "" : "s") · \(e) exercise\(e == 1 ? "" : "s")"
}
var body: some View {
HStack(spacing: 12) {
// Tappable body program detail
Button { showDetail = true } label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10).fill(AppColors.accentSoft)
ProgramIcon(name: program.emoji, size: 20, color: AppColors.accent)
}
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text(program.name).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Text(subtitle).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
}
}
.buttonStyle(.plain)
Spacer()
Button { showRename = true } label: {
Image(systemName: "pencil").font(.system(size: 12))
.foregroundColor(AppColors.text3(cs))
.frame(width: 28, height: 28)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain)
if isActive {
Text("ACTIVE").font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.accent)
.padding(.horizontal, 7).padding(.vertical, 3).background(AppColors.accentSoft).cornerRadius(5)
} else {
Button { withAnimation { vm.switchProgram(to: program.id) } } label: {
Text("Switch").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
.padding(.horizontal, 8).padding(.vertical, 4)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1))
}
.buttonStyle(.plain)
Button { showConfirmDelete = true } label: {
Image(systemName: "trash").font(.system(size: 12)).foregroundColor(AppColors.red.opacity(0.7))
.frame(width: 28, height: 28).background(AppColors.surface2(cs)).clipShape(RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain)
.confirmationDialog("Delete \"\(program.name)\"?", isPresented: $showConfirmDelete, titleVisibility: .visible) {
Button("Delete", role: .destructive) { withAnimation { vm.deleteProgram(program.id) } }
}
}
}
.padding(.horizontal, 14).padding(.vertical, 10)
.sheet(isPresented: $showRename) {
RenameProgramSheet(program: program).environmentObject(vm)
.presentationDetents([.height(380)])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showDetail) {
ProgramDetailSheet(programId: program.id).environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
}
// MARK: - Program Detail Sheet
private struct SectionTarget: Identifiable { let id: UUID }
struct ProgramDetailSheet: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
let programId: UUID
@State private var showAddSection = false
@State private var addExerciseTargetSection: SectionTarget? = nil
var program: WorkoutProgram? { vm.programs.first { $0.id == programId } }
var body: some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 10) {
if let p = program {
ProgramIcon(name: p.emoji, size: 18, color: AppColors.accent)
Text(p.name).font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
}
Spacer()
Button("Done") { dismiss() }
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
if let prog = program {
if prog.sections.isEmpty {
VStack(spacing: 8) {
Image(systemName: "dumbbell").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
Text("No sections yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Text("Add a section to start building this program")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
List {
ForEach(prog.sections) { section in
Section {
if section.exercises.isEmpty {
Text("No exercises — tap + or drag one here")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity).padding(.vertical, 10)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.dropDestination(for: String.self) { items, _ in
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
withAnimation(KisaniSpring.snappy) {
vm.moveExercise(programId: programId, exerciseId: id,
toSection: section.id, before: nil)
}
return true
}
} else {
ForEach(section.exercises) { exercise in
HStack(spacing: 10) {
Image(systemName: exercise.sfSymbol)
.font(.system(size: 13, weight: .medium))
.foregroundColor(exercise.colorIndex.iconColor)
.frame(width: 28, height: 28)
.background(exercise.colorIndex.iconBg)
.cornerRadius(7)
VStack(alignment: .leading, spacing: 1) {
Text(exercise.name)
.font(AppFonts.sans(12.5, weight: .medium))
.foregroundColor(AppColors.text(cs))
Text("\(exercise.sets.count) sets")
.font(AppFonts.mono(9.5)).foregroundColor(AppColors.text3(cs))
}
Spacer()
}
.padding(.vertical, 4)
.listRowBackground(AppColors.surface(cs))
.listRowSeparator(.hidden)
.draggable(exercise.id.uuidString)
.dropDestination(for: String.self) { items, _ in
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
withAnimation(KisaniSpring.snappy) {
vm.moveExercise(programId: programId, exerciseId: id,
toSection: section.id, before: exercise.id)
}
return true
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
vm.deleteExercise(programId: programId, sectionId: section.id, exerciseId: exercise.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
// Add exercise to this section
Button {
addExerciseTargetSection = SectionTarget(id: section.id)
} label: {
HStack(spacing: 6) {
Image(systemName: "plus").font(.system(size: 11))
Text("Add Exercise").font(AppFonts.mono(10, weight: .semibold)).textCase(.uppercase)
}
.foregroundColor(AppColors.accent)
.frame(maxWidth: .infinity).padding(.vertical, 8)
.overlay(RoundedRectangle(cornerRadius: 8)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
.foregroundColor(AppColors.accent.opacity(0.4)))
}
.buttonStyle(.plain)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.vertical, 2)
.dropDestination(for: String.self) { items, _ in
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
withAnimation(KisaniSpring.snappy) {
vm.moveExercise(programId: programId, exerciseId: id,
toSection: section.id, before: nil)
}
return true
}
} header: {
HStack {
Text(section.name.uppercased())
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.text2(cs))
.textCase(nil)
Text("\(section.exercises.count)")
.font(AppFonts.mono(8.5))
.foregroundColor(AppColors.text3(cs))
.padding(.horizontal, 5).padding(.vertical, 2)
.background(AppColors.surface2(cs))
.clipShape(Capsule())
Spacer()
Button(role: .destructive) {
vm.deleteSection(from: programId, sectionId: section.id)
} label: {
Image(systemName: "trash").font(.system(size: 11)).foregroundColor(AppColors.red.opacity(0.6))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 14).padding(.vertical, 6)
.background(AppColors.background(cs))
}
}
// Add Section
Button { showAddSection = true } label: {
HStack(spacing: 8) {
Image(systemName: "folder.badge.plus").font(.system(size: 13)).foregroundColor(AppColors.accent)
Text("Add Section").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent)
}
.frame(maxWidth: .infinity).padding(11)
.overlay(RoundedRectangle(cornerRadius: AppRadius.medium)
.strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4]))
.foregroundColor(AppColors.accent.opacity(0.4)))
}
.buttonStyle(.plain)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 8, leading: 14, bottom: 20, trailing: 14))
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
}
.background(AppColors.background(cs).ignoresSafeArea())
.sheet(isPresented: $showAddSection) {
ProgramAddSectionSheet(programId: programId).environmentObject(vm)
.presentationDetents([.height(240)])
.presentationDragIndicator(.visible)
}
.sheet(item: $addExerciseTargetSection) { target in
ExercisePickerSheet(sectionId: target.id, programId: programId)
.environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
}
// MARK: - Program Add Section Sheet (for non-active programs)
private struct ProgramAddSectionSheet: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
let programId: UUID
@State private var name = ""
@FocusState private var focused: Bool
private let presets = ["Warm Up", "Main Lifts", "Accessories", "Cool Down", "Cardio", "Supersets"]
var body: some View {
VStack(spacing: 0) {
HStack {
Text("New Section").font(AppFonts.sans(16, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
VStack(alignment: .leading, spacing: 12) {
TextField("e.g. Warm Up, Main Lifts…", text: $name)
.font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
.focused($focused).padding(13)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(focused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(presets, id: \.self) { preset in
Button { name = preset } label: {
Text(preset)
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(name == preset ? AppColors.accent : AppColors.text3(cs))
.padding(.horizontal, 10).padding(.vertical, 5)
.background(name == preset ? AppColors.accentSoft : AppColors.surface2(cs))
.clipShape(Capsule())
.overlay(Capsule().stroke(name == preset ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
}
.buttonStyle(.plain)
}
}
}
}
.padding(.horizontal, 20).padding(.top, 14)
Spacer()
Button {
guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return }
vm.addSection(to: programId, name: name.trimmingCharacters(in: .whitespaces))
dismiss()
} label: {
Text("Add Section").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white)
.frame(maxWidth: .infinity).padding(14)
.background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
}
.buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 20).padding(.bottom, 20)
}
.background(AppColors.background(cs).ignoresSafeArea())
.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { focused = true } }
}
}
// MARK: - Day Schedule Cell
private struct DayScheduleCell: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
let day: String; let weekdayNum: Int
@State private var showPicker = false
var assignedProgram: WorkoutProgram? {
guard let pid = vm.schedule[weekdayNum] else { return nil }
return vm.programs.first { $0.id == pid }
}
var isToday: Bool { Calendar.current.component(.weekday, from: Date()) == weekdayNum }
var body: some View {
Button { showPicker = true } label: {
VStack(spacing: 4) {
Text(day).font(AppFonts.mono(8.5, weight: .bold))
.foregroundColor(isToday ? AppColors.accent : AppColors.text3(cs))
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(assignedProgram != nil ? AppColors.accentSoft : AppColors.surface2(cs))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(isToday ? AppColors.accent : AppColors.border(cs), lineWidth: isToday ? 1.5 : 1))
if let prog = assignedProgram {
ProgramIcon(name: prog.emoji, size: 17, color: AppColors.accent)
} else {
Image(systemName: "minus")
.font(.system(size: 12))
.foregroundColor(AppColors.text3(cs))
}
}
.frame(height: 44)
Text(assignedProgram?.name.components(separatedBy: " ").first ?? "REST")
.font(AppFonts.mono(7, weight: .bold))
.foregroundColor(AppColors.text3(cs)).lineLimit(1)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.confirmationDialog("Assign \(day)", isPresented: $showPicker, titleVisibility: .visible) {
ForEach(vm.programs) { prog in
Button(prog.name) { vm.setSchedule(weekday: weekdayNum, programId: prog.id) }
}
Button("Rest Day", role: .destructive) { vm.setSchedule(weekday: weekdayNum, programId: nil) }
Button("Cancel", role: .cancel) {}
}
}
}
// MARK: - Rename Program Sheet
struct RenameProgramSheet: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
let program: WorkoutProgram
@State private var name: String
@State private var selectedEmoji: String
@FocusState private var nameFocused: Bool
private let iconSymbols = [
"figure.strengthtraining.traditional", "figure.strengthtraining.functional",
"figure.run", "figure.highintensity.intervaltraining",
"figure.yoga", "figure.cycling",
"figure.boxing", "figure.core.training",
"figure.cooldown", "figure.arms.open",
"dumbbell.fill", "heart.fill",
"bolt.fill", "figure.walk",
"figure.jumprope", "trophy.fill"
]
init(program: WorkoutProgram) {
self.program = program
_name = State(initialValue: program.name)
_selectedEmoji = State(initialValue: program.emoji)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Rename Program").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 7) {
Text("PROGRAM NAME").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
TextField("Program name", text: $name)
.font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
.focused($nameFocused).padding(13)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(nameFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
}
VStack(alignment: .leading, spacing: 7) {
Text("ICON").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
ForEach(iconSymbols, id: \.self) { sym in
Button { withAnimation(KisaniSpring.micro) { selectedEmoji = sym } } label: {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(selectedEmoji == sym ? AppColors.accentSoft : AppColors.surface2(cs))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(
selectedEmoji == sym ? AppColors.accent : AppColors.border(cs),
lineWidth: selectedEmoji == sym ? 1.5 : 1
))
Image(systemName: sym)
.font(.system(size: 20, weight: .medium))
.foregroundColor(selectedEmoji == sym ? AppColors.accent : AppColors.text2(cs))
}
.frame(height: 52)
}
.buttonStyle(.plain)
}
}
}
}
.padding(.horizontal, 20).padding(.top, 18).padding(.bottom, 16)
}
Divider().background(AppColors.border(cs))
Button {
guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return }
vm.renameProgram(program.id, name: name.trimmingCharacters(in: .whitespaces), emoji: selectedEmoji)
dismiss()
} label: {
Text("Save Changes").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white)
.frame(maxWidth: .infinity).padding(14)
.background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
}
.buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 20).padding(.vertical, 14)
}
.background(AppColors.background(cs).ignoresSafeArea())
.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { nameFocused = true } }
}
}
// MARK: - Add Program Sheet
struct AddProgramSheet: View {
@EnvironmentObject var vm: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
@State private var name = ""
@State private var selectedEmoji = "figure.strengthtraining.traditional"
@FocusState private var nameFocused: Bool
private let iconSymbols = [
"figure.strengthtraining.traditional", "figure.strengthtraining.functional",
"figure.run", "figure.highintensity.intervaltraining",
"figure.yoga", "figure.cycling",
"figure.boxing", "figure.core.training",
"figure.cooldown", "figure.arms.open",
"dumbbell.fill", "heart.fill",
"bolt.fill", "figure.walk",
"figure.jumprope", "trophy.fill"
]
var body: some View {
VStack(spacing: 0) {
HStack {
Text("New Program").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 7) {
Text("PROGRAM NAME").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
TextField("e.g. Push Day, Cardio…", text: $name)
.font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
.focused($nameFocused).padding(13)
.background(AppColors.surface2(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(nameFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
}
VStack(alignment: .leading, spacing: 7) {
Text("ICON").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) {
ForEach(iconSymbols, id: \.self) { sym in
Button { withAnimation(KisaniSpring.micro) { selectedEmoji = sym } } label: {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(selectedEmoji == sym ? AppColors.accentSoft : AppColors.surface2(cs))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(
selectedEmoji == sym ? AppColors.accent : AppColors.border(cs),
lineWidth: selectedEmoji == sym ? 1.5 : 1
))
Image(systemName: sym)
.font(.system(size: 20, weight: .medium))
.foregroundColor(selectedEmoji == sym ? AppColors.accent : AppColors.text2(cs))
}
.frame(height: 52)
}
.buttonStyle(.plain)
}
}
}
}
.padding(.horizontal, 20).padding(.top, 18).padding(.bottom, 16)
}
Divider().background(AppColors.border(cs))
Button {
guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return }
vm.addProgram(name: name.trimmingCharacters(in: .whitespaces), emoji: selectedEmoji)
dismiss()
} label: {
Text("Create Program").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white)
.frame(maxWidth: .infinity).padding(14)
.background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.animation(KisaniSpring.micro, value: name.isEmpty)
}
.buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 20).padding(.vertical, 14)
}
.background(AppColors.background(cs).ignoresSafeArea())
.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { nameFocused = true } }
}
}