1.5.0 Initial run as iOS in a monorepo
This commit is contained in:
554
ios/OpenClimb/Views/AddEdit/AddAttemptView.swift
Normal file
554
ios/OpenClimb/Views/AddEdit/AddAttemptView.swift
Normal file
@@ -0,0 +1,554 @@
|
||||
//
|
||||
// AddAttemptView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddAttemptView: View {
|
||||
let session: ClimbSession
|
||||
let gym: Gym
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedProblem: Problem?
|
||||
@State private var selectedResult: AttemptResult = .fall
|
||||
@State private var highestHold = ""
|
||||
@State private var notes = ""
|
||||
@State private var duration: Int = 0
|
||||
@State private var restTime: Int = 0
|
||||
@State private var showingCreateProblem = false
|
||||
|
||||
// New problem creation state
|
||||
@State private var newProblemName = ""
|
||||
@State private var newProblemGrade = ""
|
||||
@State private var selectedClimbType: ClimbType = .boulder
|
||||
@State private var selectedDifficultySystem: DifficultySystem = .vScale
|
||||
|
||||
private var activeProblems: [Problem] {
|
||||
dataManager.activeProblems(forGym: gym.id)
|
||||
}
|
||||
|
||||
private var availableClimbTypes: [ClimbType] {
|
||||
gym.supportedClimbTypes
|
||||
}
|
||||
|
||||
private var availableDifficultySystems: [DifficultySystem] {
|
||||
DifficultySystem.systemsForClimbType(selectedClimbType).filter { system in
|
||||
gym.difficultySystems.contains(system)
|
||||
}
|
||||
}
|
||||
|
||||
private var availableGrades: [String] {
|
||||
selectedDifficultySystem.availableGrades
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
if !showingCreateProblem {
|
||||
ProblemSelectionSection()
|
||||
} else {
|
||||
CreateProblemSection()
|
||||
}
|
||||
|
||||
AttemptDetailsSection()
|
||||
}
|
||||
.navigationTitle("Add Attempt")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Add") {
|
||||
saveAttempt()
|
||||
}
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setupInitialValues()
|
||||
}
|
||||
.onChange(of: selectedClimbType) { _ in
|
||||
updateDifficultySystem()
|
||||
}
|
||||
.onChange(of: selectedDifficultySystem) { _ in
|
||||
resetGradeIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ProblemSelectionSection() -> some View {
|
||||
Section("Select Problem") {
|
||||
if activeProblems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("No active problems in this gym")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button("Create New Problem") {
|
||||
showingCreateProblem = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ForEach(activeProblems, id: \.id) { problem in
|
||||
ProblemSelectionRow(
|
||||
problem: problem,
|
||||
isSelected: selectedProblem?.id == problem.id
|
||||
) {
|
||||
selectedProblem = problem
|
||||
}
|
||||
}
|
||||
|
||||
Button("Create New Problem") {
|
||||
showingCreateProblem = true
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func CreateProblemSection() -> some View {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Create New Problem")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Back") {
|
||||
showingCreateProblem = false
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Problem Details") {
|
||||
TextField("Problem Name", text: $newProblemName)
|
||||
}
|
||||
|
||||
Section("Climb Type") {
|
||||
ForEach(availableClimbTypes, id: \.self) { climbType in
|
||||
HStack {
|
||||
Text(climbType.displayName)
|
||||
Spacer()
|
||||
if selectedClimbType == climbType {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedClimbType = climbType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Difficulty") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Difficulty System")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
ForEach(availableDifficultySystems, id: \.self) { system in
|
||||
HStack {
|
||||
Text(system.displayName)
|
||||
Spacer()
|
||||
if selectedDifficultySystem == system {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedDifficultySystem = system
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedDifficultySystem == .custom {
|
||||
TextField("Grade (Required)", text: $newProblemGrade)
|
||||
.keyboardType(.numberPad)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Grade (Required)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 8) {
|
||||
ForEach(availableGrades, id: \.self) { grade in
|
||||
Button(grade) {
|
||||
newProblemGrade = grade
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.tint(newProblemGrade == grade ? .blue : .gray)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func AttemptDetailsSection() -> some View {
|
||||
Section("Attempt Result") {
|
||||
ForEach(AttemptResult.allCases, id: \.self) { result in
|
||||
HStack {
|
||||
Text(result.displayName)
|
||||
Spacer()
|
||||
if selectedResult == result {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedResult = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Additional Details") {
|
||||
TextField("Highest Hold (Optional)", text: $highestHold)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Notes (Optional)")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 80)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.quaternary)
|
||||
)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Duration (seconds)")
|
||||
Spacer()
|
||||
TextField("0", value: $duration, format: .number)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Rest Time (seconds)")
|
||||
Spacer()
|
||||
TextField("0", value: $restTime, format: .number)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
if showingCreateProblem {
|
||||
return !newProblemGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
} else {
|
||||
return selectedProblem != nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setupInitialValues() {
|
||||
// Auto-select climb type if there's only one available
|
||||
if gym.supportedClimbTypes.count == 1 {
|
||||
selectedClimbType = gym.supportedClimbTypes.first!
|
||||
}
|
||||
|
||||
updateDifficultySystem()
|
||||
}
|
||||
|
||||
private func updateDifficultySystem() {
|
||||
let available = availableDifficultySystems
|
||||
|
||||
if !available.contains(selectedDifficultySystem) {
|
||||
selectedDifficultySystem = available.first ?? .custom
|
||||
}
|
||||
|
||||
if available.count == 1 {
|
||||
selectedDifficultySystem = available.first!
|
||||
}
|
||||
}
|
||||
|
||||
private func resetGradeIfNeeded() {
|
||||
let availableGrades = selectedDifficultySystem.availableGrades
|
||||
if !availableGrades.isEmpty && !availableGrades.contains(newProblemGrade) {
|
||||
newProblemGrade = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAttempt() {
|
||||
if showingCreateProblem {
|
||||
let difficulty = DifficultyGrade(
|
||||
system: selectedDifficultySystem, grade: newProblemGrade)
|
||||
|
||||
let newProblem = Problem(
|
||||
gymId: gym.id,
|
||||
name: newProblemName.isEmpty ? nil : newProblemName,
|
||||
climbType: selectedClimbType,
|
||||
difficulty: difficulty
|
||||
)
|
||||
|
||||
dataManager.addProblem(newProblem)
|
||||
|
||||
let attempt = Attempt(
|
||||
sessionId: session.id,
|
||||
problemId: newProblem.id,
|
||||
result: selectedResult,
|
||||
highestHold: highestHold.isEmpty ? nil : highestHold,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
duration: duration == 0 ? nil : duration,
|
||||
restTime: restTime == 0 ? nil : restTime,
|
||||
timestamp: Date()
|
||||
)
|
||||
|
||||
dataManager.addAttempt(attempt)
|
||||
} else {
|
||||
guard let problem = selectedProblem else { return }
|
||||
|
||||
let attempt = Attempt(
|
||||
sessionId: session.id,
|
||||
problemId: problem.id,
|
||||
result: selectedResult,
|
||||
highestHold: highestHold.isEmpty ? nil : highestHold,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
duration: duration > 0 ? duration : nil,
|
||||
restTime: restTime > 0 ? restTime : nil
|
||||
)
|
||||
|
||||
dataManager.addAttempt(attempt)
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct ProblemSelectionRow: View {
|
||||
let problem: Problem
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(problem.name ?? "Unnamed Problem")
|
||||
.font(.headline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
if let location = problem.location {
|
||||
Text(location)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct EditAttemptView: View {
|
||||
let attempt: Attempt
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedProblem: Problem?
|
||||
@State private var selectedResult: AttemptResult
|
||||
@State private var highestHold: String
|
||||
@State private var notes: String
|
||||
@State private var duration: Int
|
||||
@State private var restTime: Int
|
||||
|
||||
private var availableProblems: [Problem] {
|
||||
dataManager.problems.filter { $0.isActive }
|
||||
}
|
||||
|
||||
init(attempt: Attempt) {
|
||||
self.attempt = attempt
|
||||
self._selectedResult = State(initialValue: attempt.result)
|
||||
self._highestHold = State(initialValue: attempt.highestHold ?? "")
|
||||
self._notes = State(initialValue: attempt.notes ?? "")
|
||||
self._duration = State(initialValue: attempt.duration ?? 0)
|
||||
self._restTime = State(initialValue: attempt.restTime ?? 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("Problem") {
|
||||
if availableProblems.isEmpty {
|
||||
Text("No problems available")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(availableProblems, id: \.id) { problem in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(problem.name ?? "Unnamed Problem")
|
||||
.font(.headline)
|
||||
|
||||
Text(
|
||||
"\(problem.difficulty.system.displayName): \(problem.difficulty.grade)"
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedProblem?.id == problem.id {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedProblem = problem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Result") {
|
||||
ForEach(AttemptResult.allCases, id: \.self) { result in
|
||||
HStack {
|
||||
Text(result.displayName)
|
||||
Spacer()
|
||||
if selectedResult == result {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedResult = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Details") {
|
||||
TextField("Highest Hold (Optional)", text: $highestHold)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Notes (Optional)")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 80)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.quaternary)
|
||||
)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Duration (seconds)")
|
||||
Spacer()
|
||||
TextField("0", value: $duration, format: .number)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Rest Time (seconds)")
|
||||
Spacer()
|
||||
TextField("0", value: $restTime, format: .number)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Attempt")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Update") {
|
||||
updateAttempt()
|
||||
}
|
||||
.disabled(selectedProblem == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedProblem = dataManager.problem(withId: attempt.problemId)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAttempt() {
|
||||
guard let problem = selectedProblem else { return }
|
||||
|
||||
let updatedAttempt = attempt.updated(
|
||||
result: selectedResult,
|
||||
highestHold: highestHold.isEmpty ? nil : highestHold,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
duration: duration > 0 ? duration : nil,
|
||||
restTime: restTime > 0 ? restTime : nil
|
||||
)
|
||||
|
||||
dataManager.updateAttempt(updatedAttempt)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddAttemptView(
|
||||
session: ClimbSession(gymId: UUID()),
|
||||
gym: Gym(
|
||||
name: "Sample Gym",
|
||||
supportedClimbTypes: [.boulder],
|
||||
difficultySystems: [.vScale]
|
||||
)
|
||||
)
|
||||
.environmentObject(ClimbingDataManager.preview)
|
||||
}
|
||||
216
ios/OpenClimb/Views/AddEdit/AddEditGymView.swift
Normal file
216
ios/OpenClimb/Views/AddEdit/AddEditGymView.swift
Normal file
@@ -0,0 +1,216 @@
|
||||
//
|
||||
// AddEditGymView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddEditGymView: View {
|
||||
let gymId: UUID?
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var location = ""
|
||||
@State private var notes = ""
|
||||
@State private var selectedClimbTypes = Set<ClimbType>()
|
||||
@State private var selectedDifficultySystems = Set<DifficultySystem>()
|
||||
@State private var customDifficultyGrades: [String] = []
|
||||
@State private var isEditing = false
|
||||
|
||||
private var existingGym: Gym? {
|
||||
guard let gymId = gymId else { return nil }
|
||||
return dataManager.gym(withId: gymId)
|
||||
}
|
||||
|
||||
private var availableDifficultySystems: [DifficultySystem] {
|
||||
if selectedClimbTypes.isEmpty {
|
||||
return []
|
||||
} else {
|
||||
return selectedClimbTypes.flatMap { climbType in
|
||||
DifficultySystem.systemsForClimbType(climbType)
|
||||
}.removingDuplicates()
|
||||
}
|
||||
}
|
||||
|
||||
init(gymId: UUID? = nil) {
|
||||
self.gymId = gymId
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
BasicInfoSection()
|
||||
ClimbTypesSection()
|
||||
DifficultySystemsSection()
|
||||
NotesSection()
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Gym" : "Add Gym")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
saveGym()
|
||||
}
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadExistingGym()
|
||||
}
|
||||
.onChange(of: selectedClimbTypes) { _ in
|
||||
updateAvailableDifficultySystems()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func BasicInfoSection() -> some View {
|
||||
Section("Basic Information") {
|
||||
TextField("Gym Name", text: $name)
|
||||
|
||||
TextField("Location (Optional)", text: $location)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ClimbTypesSection() -> some View {
|
||||
Section("Supported Climb Types") {
|
||||
ForEach(ClimbType.allCases, id: \.self) { climbType in
|
||||
HStack {
|
||||
Text(climbType.displayName)
|
||||
Spacer()
|
||||
if selectedClimbTypes.contains(climbType) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if selectedClimbTypes.contains(climbType) {
|
||||
selectedClimbTypes.remove(climbType)
|
||||
} else {
|
||||
selectedClimbTypes.insert(climbType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func DifficultySystemsSection() -> some View {
|
||||
Section("Difficulty Systems") {
|
||||
if selectedClimbTypes.isEmpty {
|
||||
Text("Select climb types first to see available difficulty systems")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
} else {
|
||||
ForEach(availableDifficultySystems, id: \.self) { system in
|
||||
HStack {
|
||||
Text(system.displayName)
|
||||
Spacer()
|
||||
if selectedDifficultySystems.contains(system) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if selectedDifficultySystems.contains(system) {
|
||||
selectedDifficultySystems.remove(system)
|
||||
} else {
|
||||
selectedDifficultySystems.insert(system)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func NotesSection() -> some View {
|
||||
Section("Notes (Optional)") {
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 100)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !selectedClimbTypes.isEmpty
|
||||
&& !selectedDifficultySystems.isEmpty
|
||||
}
|
||||
|
||||
private func loadExistingGym() {
|
||||
if let gym = existingGym {
|
||||
isEditing = true
|
||||
name = gym.name
|
||||
location = gym.location ?? ""
|
||||
notes = gym.notes ?? ""
|
||||
selectedClimbTypes = Set(gym.supportedClimbTypes)
|
||||
selectedDifficultySystems = Set(gym.difficultySystems)
|
||||
customDifficultyGrades = gym.customDifficultyGrades
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAvailableDifficultySystems() {
|
||||
// Remove selected systems that are no longer available
|
||||
let availableSet = Set(availableDifficultySystems)
|
||||
selectedDifficultySystems = selectedDifficultySystems.intersection(availableSet)
|
||||
}
|
||||
|
||||
private func saveGym() {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if isEditing, let gym = existingGym {
|
||||
let updatedGym = gym.updated(
|
||||
name: trimmedName,
|
||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||
supportedClimbTypes: Array(selectedClimbTypes),
|
||||
difficultySystems: Array(selectedDifficultySystems),
|
||||
customDifficultyGrades: customDifficultyGrades,
|
||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||
)
|
||||
dataManager.updateGym(updatedGym)
|
||||
} else {
|
||||
let newGym = Gym(
|
||||
name: trimmedName,
|
||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||
supportedClimbTypes: Array(selectedClimbTypes),
|
||||
difficultySystems: Array(selectedDifficultySystems),
|
||||
customDifficultyGrades: customDifficultyGrades,
|
||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||
)
|
||||
dataManager.addGym(newGym)
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element: Hashable {
|
||||
func removingDuplicates() -> [Element] {
|
||||
var seen = Set<Element>()
|
||||
return filter { seen.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddEditGymView()
|
||||
.environmentObject(ClimbingDataManager.preview)
|
||||
}
|
||||
529
ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift
Normal file
529
ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift
Normal file
@@ -0,0 +1,529 @@
|
||||
//
|
||||
// AddEditProblemView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
struct AddEditProblemView: View {
|
||||
let problemId: UUID?
|
||||
let gymId: UUID?
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedGym: Gym?
|
||||
@State private var name = ""
|
||||
@State private var description = ""
|
||||
@State private var selectedClimbType: ClimbType = .boulder
|
||||
@State private var selectedDifficultySystem: DifficultySystem = .vScale
|
||||
@State private var difficultyGrade = ""
|
||||
@State private var setter = ""
|
||||
@State private var location = ""
|
||||
@State private var tags = ""
|
||||
@State private var notes = ""
|
||||
@State private var isActive = true
|
||||
@State private var dateSet = Date()
|
||||
@State private var imagePaths: [String] = []
|
||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||
@State private var imageData: [Data] = []
|
||||
@State private var isEditing = false
|
||||
|
||||
private var existingProblem: Problem? {
|
||||
guard let problemId = problemId else { return nil }
|
||||
return dataManager.problem(withId: problemId)
|
||||
}
|
||||
|
||||
private var availableClimbTypes: [ClimbType] {
|
||||
selectedGym?.supportedClimbTypes ?? ClimbType.allCases
|
||||
}
|
||||
|
||||
var availableDifficultySystems: [DifficultySystem] {
|
||||
guard let gym = selectedGym else {
|
||||
return DifficultySystem.systemsForClimbType(selectedClimbType)
|
||||
}
|
||||
|
||||
let compatibleSystems = DifficultySystem.systemsForClimbType(selectedClimbType)
|
||||
let gymSupportedSystems = gym.difficultySystems.filter { system in
|
||||
compatibleSystems.contains(system)
|
||||
}
|
||||
|
||||
return gymSupportedSystems.isEmpty ? compatibleSystems : gymSupportedSystems
|
||||
}
|
||||
|
||||
private var availableGrades: [String] {
|
||||
selectedDifficultySystem.availableGrades
|
||||
}
|
||||
|
||||
init(problemId: UUID? = nil, gymId: UUID? = nil) {
|
||||
self.problemId = problemId
|
||||
self.gymId = gymId
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
GymSelectionSection()
|
||||
BasicInfoSection()
|
||||
ClimbTypeSection()
|
||||
DifficultySection()
|
||||
LocationAndSetterSection()
|
||||
TagsSection()
|
||||
PhotosSection()
|
||||
AdditionalInfoSection()
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
saveProblem()
|
||||
}
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadExistingProblem()
|
||||
setupInitialGym()
|
||||
}
|
||||
.onChange(of: selectedGym) { _ in
|
||||
updateAvailableOptions()
|
||||
}
|
||||
.onChange(of: selectedClimbType) { _ in
|
||||
updateDifficultySystem()
|
||||
}
|
||||
.onChange(of: selectedDifficultySystem) { _ in
|
||||
resetGradeIfNeeded()
|
||||
}
|
||||
.onChange(of: selectedPhotos) { _ in
|
||||
Task {
|
||||
await loadSelectedPhotos()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func GymSelectionSection() -> some View {
|
||||
Section("Select Gym") {
|
||||
if dataManager.gyms.isEmpty {
|
||||
Text("No gyms available. Add a gym first.")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(dataManager.gyms, id: \.id) { gym in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(gym.name)
|
||||
.font(.headline)
|
||||
|
||||
if let location = gym.location, !location.isEmpty {
|
||||
Text(location)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedGym?.id == gym.id {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedGym = gym
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func BasicInfoSection() -> some View {
|
||||
Section("Problem Details") {
|
||||
TextField("Problem Name (Optional)", text: $name)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Description (Optional)")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $description)
|
||||
.frame(minHeight: 80)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.quaternary)
|
||||
)
|
||||
}
|
||||
|
||||
TextField("Route Setter (Optional)", text: $setter)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ClimbTypeSection() -> some View {
|
||||
if let gym = selectedGym {
|
||||
Section("Climb Type") {
|
||||
ForEach(availableClimbTypes, id: \.self) { climbType in
|
||||
HStack {
|
||||
Text(climbType.displayName)
|
||||
Spacer()
|
||||
if selectedClimbType == climbType {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedClimbType = climbType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func DifficultySection() -> some View {
|
||||
Section("Difficulty") {
|
||||
// Difficulty System
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Difficulty System")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(availableDifficultySystems, id: \.self) { system in
|
||||
HStack {
|
||||
Text(system.displayName)
|
||||
Spacer()
|
||||
if selectedDifficultySystem == system {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedDifficultySystem = system
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grade Selection
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Grade (Required)")
|
||||
.font(.headline)
|
||||
|
||||
if selectedDifficultySystem == .custom || availableGrades.isEmpty {
|
||||
TextField("Enter custom grade", text: $difficultyGrade)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
Menu {
|
||||
if !difficultyGrade.isEmpty {
|
||||
Button("Clear Selection") {
|
||||
difficultyGrade = ""
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
ForEach(availableGrades, id: \.self) { grade in
|
||||
Button(grade) {
|
||||
difficultyGrade = grade
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(difficultyGrade.isEmpty ? "Select Grade" : difficultyGrade)
|
||||
.foregroundColor(difficultyGrade.isEmpty ? .secondary : .primary)
|
||||
.fontWeight(difficultyGrade.isEmpty ? .regular : .semibold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.gray.opacity(0.1))
|
||||
.stroke(
|
||||
difficultyGrade.isEmpty
|
||||
? .red.opacity(0.5) : .gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if difficultyGrade.isEmpty {
|
||||
Text("Please select a grade to continue")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
} else {
|
||||
Text("Selected: \(difficultyGrade)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func LocationAndSetterSection() -> some View {
|
||||
Section("Location & Details") {
|
||||
TextField(
|
||||
"Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'"))
|
||||
|
||||
DatePicker(
|
||||
"Date Set",
|
||||
selection: $dateSet,
|
||||
displayedComponents: [.date]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func TagsSection() -> some View {
|
||||
Section("Tags (Optional)") {
|
||||
TextField("Tags", text: $tags, prompt: Text("e.g., crimpy, dynamic (comma-separated)"))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func PhotosSection() -> some View {
|
||||
Section("Photos") {
|
||||
PhotosPicker(
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 5,
|
||||
matching: .images
|
||||
) {
|
||||
HStack {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.foregroundColor(.blue)
|
||||
Text("Add Photos (\(imageData.count)/5)")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
if !imageData.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(imageData.indices, id: \.self) { index in
|
||||
if let uiImage = UIImage(data: imageData[index]) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Button(action: {
|
||||
imageData.remove(at: index)
|
||||
if index < imagePaths.count {
|
||||
imagePaths.remove(at: index)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
.background(Circle().fill(.white))
|
||||
}
|
||||
.offset(x: 8, y: -8)
|
||||
}
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.gray.opacity(0.3))
|
||||
.frame(width: 80, height: 80)
|
||||
.overlay {
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func AdditionalInfoSection() -> some View {
|
||||
Section("Additional Information") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Notes (Optional)")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 80)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.quaternary)
|
||||
)
|
||||
}
|
||||
|
||||
Toggle("Problem is currently active", isOn: $isActive)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
selectedGym != nil
|
||||
&& !difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private func setupInitialGym() {
|
||||
if let gymId = gymId, selectedGym == nil {
|
||||
selectedGym = dataManager.gym(withId: gymId)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExistingProblem() {
|
||||
if let problem = existingProblem {
|
||||
isEditing = true
|
||||
selectedGym = dataManager.gym(withId: problem.gymId)
|
||||
name = problem.name ?? ""
|
||||
description = problem.description ?? ""
|
||||
selectedClimbType = problem.climbType
|
||||
selectedDifficultySystem = problem.difficulty.system
|
||||
difficultyGrade = problem.difficulty.grade
|
||||
setter = problem.setter ?? ""
|
||||
location = problem.location ?? ""
|
||||
tags = problem.tags.joined(separator: ", ")
|
||||
notes = problem.notes ?? ""
|
||||
isActive = problem.isActive
|
||||
imagePaths = problem.imagePaths
|
||||
|
||||
// Load image data for preview
|
||||
imageData = []
|
||||
for imagePath in problem.imagePaths {
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: imagePath)) {
|
||||
imageData.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
if let dateSet = problem.dateSet {
|
||||
self.dateSet = dateSet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAvailableOptions() {
|
||||
guard let gym = selectedGym else { return }
|
||||
|
||||
// Auto-select climb type if there's only one available
|
||||
if gym.supportedClimbTypes.count == 1, selectedClimbType != gym.supportedClimbTypes.first! {
|
||||
selectedClimbType = gym.supportedClimbTypes.first!
|
||||
}
|
||||
|
||||
updateDifficultySystem()
|
||||
}
|
||||
|
||||
private func updateDifficultySystem() {
|
||||
let available = availableDifficultySystems
|
||||
|
||||
if !available.contains(selectedDifficultySystem) {
|
||||
selectedDifficultySystem = available.first ?? .custom
|
||||
}
|
||||
|
||||
if available.count == 1, selectedDifficultySystem != available.first! {
|
||||
selectedDifficultySystem = available.first!
|
||||
}
|
||||
}
|
||||
|
||||
private func resetGradeIfNeeded() {
|
||||
let availableGrades = selectedDifficultySystem.availableGrades
|
||||
if !availableGrades.isEmpty && !availableGrades.contains(difficultyGrade) {
|
||||
difficultyGrade = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSelectedPhotos() async {
|
||||
for item in selectedPhotos {
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
// Save to app's documents directory
|
||||
let documentsPath = FileManager.default.urls(
|
||||
for: .documentDirectory, in: .userDomainMask
|
||||
).first!
|
||||
let imageName = "photo_\(UUID().uuidString).jpg"
|
||||
let imagePath = documentsPath.appendingPathComponent(imageName)
|
||||
|
||||
do {
|
||||
try data.write(to: imagePath)
|
||||
imagePaths.append(imagePath.path)
|
||||
imageData.append(data)
|
||||
} catch {
|
||||
print("Failed to save image: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedPhotos.removeAll()
|
||||
}
|
||||
|
||||
private func saveProblem() {
|
||||
guard let gym = selectedGym else { return }
|
||||
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedSetter = setter.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedTags = tags.split(separator: ",").map {
|
||||
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}.filter { !$0.isEmpty }
|
||||
|
||||
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
|
||||
|
||||
if isEditing, let problem = existingProblem {
|
||||
let updatedProblem = problem.updated(
|
||||
name: trimmedName.isEmpty ? nil : trimmedName,
|
||||
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
||||
climbType: selectedClimbType,
|
||||
difficulty: difficulty,
|
||||
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
|
||||
tags: trimmedTags,
|
||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||
imagePaths: imagePaths,
|
||||
isActive: isActive,
|
||||
dateSet: dateSet,
|
||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||
)
|
||||
dataManager.updateProblem(updatedProblem)
|
||||
} else {
|
||||
let newProblem = Problem(
|
||||
gymId: gym.id,
|
||||
name: trimmedName.isEmpty ? nil : trimmedName,
|
||||
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
||||
climbType: selectedClimbType,
|
||||
difficulty: difficulty,
|
||||
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
|
||||
tags: trimmedTags,
|
||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||
imagePaths: imagePaths,
|
||||
dateSet: dateSet,
|
||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||
)
|
||||
dataManager.addProblem(newProblem)
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddEditProblemView()
|
||||
.environmentObject(ClimbingDataManager.preview)
|
||||
}
|
||||
143
ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift
Normal file
143
ios/OpenClimb/Views/AddEdit/AddEditSessionView.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// AddEditSessionView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddEditSessionView: View {
|
||||
let sessionId: UUID?
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedGym: Gym?
|
||||
@State private var sessionDate = Date()
|
||||
@State private var notes = ""
|
||||
@State private var isEditing = false
|
||||
|
||||
private var existingSession: ClimbSession? {
|
||||
guard let sessionId = sessionId else { return nil }
|
||||
return dataManager.session(withId: sessionId)
|
||||
}
|
||||
|
||||
init(sessionId: UUID? = nil) {
|
||||
self.sessionId = sessionId
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
GymSelectionSection()
|
||||
SessionDetailsSection()
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Session" : "New Session")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
saveSession()
|
||||
}
|
||||
.disabled(selectedGym == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadExistingSession()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func GymSelectionSection() -> some View {
|
||||
Section("Select Gym") {
|
||||
if dataManager.gyms.isEmpty {
|
||||
Text("No gyms available. Add a gym first.")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(dataManager.gyms, id: \.id) { gym in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(gym.name)
|
||||
.font(.headline)
|
||||
|
||||
if let location = gym.location, !location.isEmpty {
|
||||
Text(location)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedGym?.id == gym.id {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedGym = gym
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func SessionDetailsSection() -> some View {
|
||||
Section("Session Details") {
|
||||
DatePicker(
|
||||
"Date",
|
||||
selection: $sessionDate,
|
||||
displayedComponents: [.date]
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Notes (Optional)")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 100)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.quaternary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExistingSession() {
|
||||
if let session = existingSession {
|
||||
isEditing = true
|
||||
selectedGym = dataManager.gym(withId: session.gymId)
|
||||
sessionDate = session.date
|
||||
notes = session.notes ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSession() {
|
||||
guard let gym = selectedGym else { return }
|
||||
|
||||
if isEditing, let session = existingSession {
|
||||
let updatedSession = session.updated(notes: notes.isEmpty ? nil : notes)
|
||||
dataManager.updateSession(updatedSession)
|
||||
} else {
|
||||
dataManager.startSession(gymId: gym.id, notes: notes.isEmpty ? nil : notes)
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddEditSessionView()
|
||||
.environmentObject(ClimbingDataManager.preview)
|
||||
}
|
||||
Reference in New Issue
Block a user