Self-contained Analytics UI: Overview/Daily/Weekly/Monthly/12-Month/Exercises with color+glyph status, empty/partial states, Swift Charts, VoiceOver labels, Wenza tokens, and the non-medical disclaimer. Entry via an IButton in the Workout header (sheet). Added Identifiable chart points. Self-reviewed for compile issues (fixed tuple key-paths, a11y trait literal, format string) but NOT build-verified — no iOS runtime; SwiftUI/app deps. Logged in ISSUES.md as device-required. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1922 lines
92 KiB
Swift
1922 lines
92 KiB
Swift
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
|
||
@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 { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: {
|
||
Label("Mark All Complete", systemImage: "checkmark.circle")
|
||
}
|
||
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } 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 ──
|
||
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(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)
|
||
}
|
||
.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: - 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 } }
|
||
}
|
||
}
|