1.5.0 Initial run as iOS in a monorepo

This commit is contained in:
2025-09-12 22:35:14 -06:00
parent ba6edcd854
commit ce220c7220
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)
}