1.5.0 Initial run as iOS in a monorepo

This commit is contained in:
2025-09-12 22:35:14 -06:00
parent f106244e57
commit 7da1893748
127 changed files with 7062 additions and 1039 deletions

View 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)
}

View 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)
}

View 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)
}

View 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)
}