Files
KisaniCal/KisaniCal/Views/WorkoutView.swift
Robin Kutesa 228607fde6 Add rest timer with toggle — auto-starts after each set when enabled
Floating banner shows circular progress + MM:SS countdown with skip button.
Haptic + system sound fires when timer reaches zero. Duration and on/off
toggle live in Settings → Rest Timer sheet.

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
2026-06-01 03:30:00 +03:00

1435 lines
68 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
// 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 isReordering = false
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())
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
AppColors.background(cs).ignoresSafeArea()
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: vm.workoutEmoji, size: 14, color: AppColors.accent)
Text(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: "ellipsis") { showManage = true }
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 8, leading: 18, bottom: 4, trailing: 18))
.moveDisabled(true)
// Dot Grid Progress
DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
.moveDisabled(true)
// Streak
StreakBannerView(streak: vm.streakDays)
.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(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)
}
.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)
}
}
}
.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: $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
var body: some View {
VStack(spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(done) of \(total) sets done")
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Text(total == 0 ? "Add exercises to get started"
: done == total && total > 0 ? "All sets done"
: "\(total - done) sets remaining")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
Spacer()
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
.font(AppFonts.mono(13, weight: .bold))
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
}
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)
}
}
.padding(14)
.cardStyle()
}
}
// MARK: - Streak Banner
struct StreakBannerView: View {
@Environment(\.colorScheme) private var cs
let streak: Int
var body: some View {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 2) {
Text("\(streak)")
.font(AppFonts.mono(28, weight: .bold))
.foregroundColor(AppColors.text(cs))
Text("day streak")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
}
Spacer()
// 7-day tick bar current week progress
HStack(spacing: 3) {
ForEach(0..<7, id: \.self) { i in
let filled = streak > 0 && i < (streak % 7 == 0 ? 7 : streak % 7)
RoundedRectangle(cornerRadius: 1.5)
.fill(filled ? AppColors.accent : AppColors.borderHi(cs))
.frame(width: 18, height: 3)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 13)
.cardStyle()
}
}
// 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 + to add")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity).padding(.vertical, 10)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} 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)
.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)
} 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 } }
}
}