Initial commit: KisaniCal iOS app
Task management, Eisenhower matrix, workout logging with persistence, and calendar with 6 view modes (Month, List, Year, Week, 3 Day, Day). Custom floating tab bar, dynamic calendar icon, and workout data seeded.
This commit is contained in:
261
KisaniCal/Views/AddExerciseSheet.swift
Normal file
261
KisaniCal/Views/AddExerciseSheet.swift
Normal file
@@ -0,0 +1,261 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Exercise Picker Sheet
|
||||
struct ExercisePickerSheet: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
let sectionId: UUID
|
||||
var programId: UUID? = nil // nil = active program
|
||||
|
||||
@State private var selectedMuscle: ExerciseMuscle = .chest
|
||||
@State private var searchText = ""
|
||||
@State private var customName = ""
|
||||
@State private var setsText = "3"
|
||||
@State private var repsText = "10"
|
||||
@State private var showCustom = false
|
||||
@FocusState private var customFocused: Bool
|
||||
|
||||
private func addExercise(name: String) {
|
||||
let sets = Int(setsText) ?? 3
|
||||
let reps = Int(repsText) ?? 10
|
||||
if let pid = programId {
|
||||
vm.addExercise(programId: pid, sectionId: sectionId, name: name, setsCount: sets, repsCount: reps)
|
||||
} else {
|
||||
vm.addExercise(to: sectionId, name: name, setsCount: sets, repsCount: reps)
|
||||
}
|
||||
}
|
||||
|
||||
private var filtered: [ExerciseTemplate] {
|
||||
let byMuscle = exerciseLibrary.filter { $0.muscle == selectedMuscle }
|
||||
guard !searchText.isEmpty else { return byMuscle }
|
||||
return byMuscle.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// ── Header ──
|
||||
HStack {
|
||||
Text("Add Exercise")
|
||||
.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, 12)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
// ── Search ──
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
TextField("Search exercises…", text: $searchText)
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
if !searchText.isEmpty {
|
||||
Button { searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
||||
|
||||
// ── Category Tabs ──
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(ExerciseMuscle.allCases) { muscle in
|
||||
let isSel = selectedMuscle == muscle
|
||||
Button { withAnimation(KisaniSpring.micro) { selectedMuscle = muscle } } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: muscle.sfSymbol).font(.system(size: 10))
|
||||
Text(muscle.rawValue).font(AppFonts.mono(9.5, weight: .bold))
|
||||
}
|
||||
.foregroundColor(isSel ? AppColors.accent : AppColors.text3(cs))
|
||||
.padding(.horizontal, 11).padding(.vertical, 6)
|
||||
.background(isSel ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||
.clipShape(Capsule())
|
||||
.overlay(Capsule().stroke(isSel ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(PressButtonStyle(scale: 0.94))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// ── Exercise List ──
|
||||
ScrollView(showsIndicators: false) {
|
||||
LazyVStack(spacing: 0) {
|
||||
// Custom exercise entry
|
||||
if showCustom {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("CUSTOM EXERCISE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
TextField("Exercise name", text: $customName)
|
||||
.font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||||
.focused($customFocused).padding(11)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(customFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
|
||||
SetRepsRow(setsText: $setsText, repsText: $repsText)
|
||||
Button {
|
||||
let n = customName.trimmingCharacters(in: .whitespaces)
|
||||
guard !n.isEmpty else { return }
|
||||
addExercise(name: n)
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Add Custom Exercise")
|
||||
.font(AppFonts.sans(13, weight: .bold)).foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity).padding(11)
|
||||
.background(!customName.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(customName.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(14)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.medium))
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.medium).stroke(AppColors.accent.opacity(0.3), lineWidth: 1))
|
||||
.padding(.horizontal, 16).padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// Library exercises
|
||||
ForEach(filtered) { template in
|
||||
ExerciseTemplateRow(template: template) {
|
||||
addExercise(name: template.name)
|
||||
dismiss()
|
||||
}
|
||||
Divider().background(AppColors.border(cs)).padding(.leading, 60)
|
||||
}
|
||||
|
||||
if filtered.isEmpty && !showCustom {
|
||||
VStack(spacing: 6) {
|
||||
Text("No exercises found")
|
||||
.font(AppFonts.sans(13, weight: .medium))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
Text("Try a different category or add a custom one")
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 30)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
// ── Footer: Default sets/reps + custom toggle ──
|
||||
Divider().background(AppColors.border(cs))
|
||||
VStack(spacing: 10) {
|
||||
SetRepsRow(setsText: $setsText, repsText: $repsText)
|
||||
Button {
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
showCustom.toggle()
|
||||
if showCustom { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { customFocused = true } }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: showCustom ? "xmark.circle" : "plus.circle")
|
||||
.font(.system(size: 13))
|
||||
Text(showCustom ? "Cancel Custom" : "Add Custom Exercise")
|
||||
.font(AppFonts.sans(12, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(AppColors.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exercise Template Row
|
||||
private struct ExerciseTemplateRow: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let template: ExerciseTemplate
|
||||
let onAdd: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onAdd) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: template.sfSymbol)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(AppColors.accentSoft)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
|
||||
Text(template.name)
|
||||
.font(AppFonts.sans(13, weight: .medium))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(AppColors.accent.opacity(0.7))
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sets / Reps Row (shared)
|
||||
private struct SetRepsRow: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
@Binding var setsText: String
|
||||
@Binding var repsText: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Sets").font(AppFonts.mono(9.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
TextField("3", text: $setsText)
|
||||
.font(AppFonts.mono(12, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
||||
.keyboardType(.numberPad).multilineTextAlignment(.center)
|
||||
.frame(width: 36).padding(.vertical, 5).padding(.horizontal, 4)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
}
|
||||
.padding(8)
|
||||
.background(AppColors.surface(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text("Reps").font(AppFonts.mono(9.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
TextField("10", text: $repsText)
|
||||
.font(AppFonts.mono(12, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
||||
.keyboardType(.numberPad).multilineTextAlignment(.center)
|
||||
.frame(width: 36).padding(.vertical, 5).padding(.horizontal, 4)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
}
|
||||
.padding(8)
|
||||
.background(AppColors.surface(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user