diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 917f898..5d4fb10 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift index e113476..0d2851e 100644 --- a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift @@ -592,9 +592,41 @@ struct EditAttemptView: View { @State private var notes: String @State private var duration: Int @State private var restTime: Int + @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 availableProblems: [Problem] { - dataManager.problems.filter { $0.isActive } + guard let session = dataManager.session(withId: attempt.sessionId) else { + return [] + } + return dataManager.problems.filter { $0.isActive && $0.gymId == session.gymId } + } + + private var gym: Gym? { + guard let session = dataManager.session(withId: attempt.sessionId) else { + return nil + } + return dataManager.gym(withId: session.gymId) + } + + private var availableClimbTypes: [ClimbType] { + gym?.supportedClimbTypes ?? [] + } + + private var availableDifficultySystems: [DifficultySystem] { + guard let gym = gym else { return [] } + return DifficultySystem.systemsForClimbType(selectedClimbType).filter { system in + gym.difficultySystems.contains(system) + } + } + + private var availableGrades: [String] { + selectedDifficultySystem.availableGrades } init(attempt: Attempt) { @@ -609,82 +641,13 @@ struct EditAttemptView: View { var body: some View { NavigationView { Form { - Section("Select Problem") { - if availableProblems.isEmpty { - Text("No problems available") - .foregroundColor(.secondary) - } else { - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), - spacing: 8 - ) { - ForEach(availableProblems, id: \.id) { problem in - ProblemSelectionCard( - problem: problem, - isSelected: selectedProblem?.id == problem.id - ) { - selectedProblem = problem - } - } - } - .padding(.vertical, 8) - } + if !showingCreateProblem { + ProblemSelectionSection() + } else { + CreateProblemSection() } - 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) - } - } + AttemptDetailsSection() } .navigationTitle("Edit Attempt") .navigationBarTitleDisplayMode(.inline) @@ -699,28 +662,293 @@ struct EditAttemptView: View { Button("Update") { updateAttempt() } - .disabled(selectedProblem == nil) + .disabled(!canSave) } } } .onAppear { selectedProblem = dataManager.problem(withId: attempt.problemId) + setupInitialValues() + } + .onChange(of: selectedClimbType) { + updateDifficultySystem() + } + .onChange(of: selectedDifficultySystem) { + resetGradeIfNeeded() + } + } + + @ViewBuilder + private func ProblemSelectionSection() -> some View { + Section("Select Problem") { + if availableProblems.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 { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), + spacing: 8 + ) { + ForEach(availableProblems, id: \.id) { problem in + ProblemSelectionCard( + problem: problem, + isSelected: selectedProblem?.id == problem.id + ) { + selectedProblem = problem + } + } + } + .padding(.vertical, 8) + + 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 - numbers only)", text: $newProblemGrade) + .keyboardType(.numberPad) + .onChange(of: newProblemGrade) { + // Filter out non-numeric characters + newProblemGrade = newProblemGrade.filter { $0.isNumber } + } + } 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() { + guard let gym = gym else { return } + + // 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 updateAttempt() { - guard selectedProblem != nil else { return } + if showingCreateProblem { + guard let gym = gym else { return } - let updatedAttempt = attempt.updated( - problemId: selectedProblem?.id, - result: selectedResult, - highestHold: highestHold.isEmpty ? nil : highestHold, - notes: notes.isEmpty ? nil : notes, - duration: duration > 0 ? duration : nil, - restTime: restTime > 0 ? restTime : nil - ) + 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 updatedAttempt = attempt.updated( + problemId: newProblem.id, + 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) + } else { + guard selectedProblem != nil else { return } + + let updatedAttempt = attempt.updated( + problemId: selectedProblem?.id, + 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) + } - dataManager.updateAttempt(updatedAttempt) dismiss() } } diff --git a/ios/OpenClimb/Views/AnalyticsView.swift b/ios/OpenClimb/Views/AnalyticsView.swift index 9f66790..828182a 100644 --- a/ios/OpenClimb/Views/AnalyticsView.swift +++ b/ios/OpenClimb/Views/AnalyticsView.swift @@ -104,13 +104,14 @@ struct StatCard: View { struct ProgressChartSection: View { @EnvironmentObject var dataManager: ClimbingDataManager @State private var selectedSystem: DifficultySystem = .vScale + @State private var showAllTime: Bool = true - private var progressData: [ProgressDataPoint] { - calculateProgressOverTime() + private var gradeCountData: [GradeCount] { + calculateGradeCounts() } private var usedSystems: [DifficultySystem] { - let uniqueSystems = Set(progressData.map { $0.difficultySystem }) + let uniqueSystems = Set(gradeCountData.map { $0.difficultySystem }) return uniqueSystems.sorted { let order: [DifficultySystem] = [.vScale, .font, .yds, .custom] let firstIndex = order.firstIndex(of: $0) ?? order.count @@ -121,13 +122,50 @@ struct ProgressChartSection: View { var body: some View { VStack(alignment: .leading, spacing: 16) { + Text("Grade Distribution") + .font(.title2) + .fontWeight(.bold) + + // Toggles section HStack { - Text("Progress Over Time") - .font(.title2) - .fontWeight(.bold) + // Time period toggle + HStack(spacing: 8) { + Button(action: { + showAllTime = true + }) { + Text("All Time") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(showAllTime ? .blue : .clear) + .stroke(.blue.opacity(0.3), lineWidth: 1) + ) + .foregroundColor(showAllTime ? .white : .blue) + } + + Button(action: { + showAllTime = false + }) { + Text("7 Days") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(!showAllTime ? .blue : .clear) + .stroke(.blue.opacity(0.3), lineWidth: 1) + ) + .foregroundColor(!showAllTime ? .white : .blue) + } + } Spacer() + // Scale selector (only show if multiple systems) if usedSystems.count > 1 { Menu { ForEach(usedSystems, id: \.self) { system in @@ -164,24 +202,22 @@ struct ProgressChartSection: View { } } - let filteredData = progressData.filter { $0.difficultySystem == selectedSystem } + let filteredData = gradeCountData.filter { $0.difficultySystem == selectedSystem } if !filteredData.isEmpty { - LineChartView(data: filteredData, selectedSystem: selectedSystem) + BarChartView(data: filteredData) .frame(height: 200) - Text( - "Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session" - ) - .font(.caption) - .foregroundColor(.secondary) + Text("Successful climbs by grade") + .font(.caption) + .foregroundColor(.secondary) } else { VStack(spacing: 8) { - Image(systemName: "chart.line.uptrend.xyaxis") + Image(systemName: "chart.bar") .font(.title) .foregroundColor(.secondary) - Text("No progress data available for \(selectedSystem.displayName) system") + Text("No data available for \(selectedSystem.displayName) system") .font(.subheadline) .foregroundColor(.secondary) } @@ -201,38 +237,125 @@ struct ProgressChartSection: View { } } - private func calculateProgressOverTime() -> [ProgressDataPoint] { - let sessions = dataManager.completedSessions().sorted { $0.date < $1.date } + private func calculateGradeCounts() -> [GradeCount] { let problems = dataManager.problems let attempts = dataManager.attempts - return sessions.compactMap { session in - let sessionAttempts = attempts.filter { $0.sessionId == session.id } - let attemptedProblemIds = sessionAttempts.map { $0.problemId } - let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) } + // Filter attempts by time period + let filteredAttempts: [Attempt] + if showAllTime { + filteredAttempts = attempts.filter { $0.result.isSuccessful } + } else { + let sevenDaysAgo = + Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date() + filteredAttempts = attempts.filter { + $0.result.isSuccessful && $0.timestamp >= sevenDaysAgo + } + } - // Group problems by difficulty system - let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system } + // Get attempted problems + let attemptedProblemIds = filteredAttempts.map { $0.problemId } + let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) } - // Create data points for each system used in this session - return problemsBySystem.compactMap { (system, systemProblems) -> ProgressDataPoint? in - guard - let highestGradeProblem = systemProblems.max(by: { - $0.difficulty.numericValue < $1.difficulty.numericValue - }) - else { - return nil - } + // Group by difficulty system and grade + var gradeCounts: [String: GradeCount] = [:] - return ProgressDataPoint( - date: session.date, - maxGrade: highestGradeProblem.difficulty.grade, - maxGradeNumeric: highestGradeProblem.difficulty.numericValue, - climbType: highestGradeProblem.climbType, - difficultySystem: system + for problem in attemptedProblems { + let successfulAttemptsForProblem = filteredAttempts.filter { + $0.problemId == problem.id + } + let count = successfulAttemptsForProblem.count + + let key = "\(problem.difficulty.system.rawValue)-\(problem.difficulty.grade)" + + if let existing = gradeCounts[key] { + gradeCounts[key] = GradeCount( + grade: existing.grade, + count: existing.count + count, + gradeNumeric: existing.gradeNumeric, + difficultySystem: existing.difficultySystem + ) + } else { + gradeCounts[key] = GradeCount( + grade: problem.difficulty.grade, + count: count, + gradeNumeric: problem.difficulty.numericValue, + difficultySystem: problem.difficulty.system ) } - }.flatMap { $0 } + } + + return Array(gradeCounts.values) + } +} + +struct GradeCount { + let grade: String + let count: Int + let gradeNumeric: Int + let difficultySystem: DifficultySystem +} + +struct BarChartView: View { + let data: [GradeCount] + + private var sortedData: [GradeCount] { + data.sorted { $0.gradeNumeric < $1.gradeNumeric } + } + + private var maxCount: Int { + data.map { $0.count }.max() ?? 1 + } + + var body: some View { + GeometryReader { geometry in + let chartWidth = geometry.size.width - 40 + let chartHeight = geometry.size.height - 40 + let barWidth = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.8 + let spacing = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.2 + + if sortedData.isEmpty { + Rectangle() + .fill(.clear) + .overlay( + Text("No data") + .foregroundColor(.secondary) + ) + } else { + VStack(alignment: .leading) { + // Chart area + HStack(alignment: .bottom, spacing: spacing / CGFloat(sortedData.count)) { + ForEach(Array(sortedData.enumerated()), id: \.offset) { index, gradeCount in + VStack(spacing: 4) { + // Bar + RoundedRectangle(cornerRadius: 4) + .fill(.blue) + .frame( + width: barWidth, + height: CGFloat(gradeCount.count) / CGFloat(maxCount) + * chartHeight * 0.8 + ) + .overlay( + Text("\(gradeCount.count)") + .font(.caption2) + .fontWeight(.medium) + .foregroundColor(.white) + .opacity(gradeCount.count > 0 ? 1 : 0) + ) + + // Grade label + Text(gradeCount.grade) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + .frame(height: chartHeight) + } + .frame(maxWidth: .infinity, alignment: .center) + } + } } } @@ -253,7 +376,7 @@ struct FavoriteGymSection: View { } var body: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 16) { HStack { Image(systemName: "location.fill") .font(.title2) @@ -380,139 +503,6 @@ struct RecentActivitySection: View { } } -struct LineChartView: View { - let data: [ProgressDataPoint] - let selectedSystem: DifficultySystem - - private var uniqueGrades: [String] { - if selectedSystem == .custom { - return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in - return (Int(grade1) ?? 0) > (Int(grade2) ?? 0) - } - } else { - return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in - let grade1Data = data.first(where: { $0.maxGrade == grade1 }) - let grade2Data = data.first(where: { $0.maxGrade == grade2 }) - return (grade1Data?.maxGradeNumeric ?? 0) - > (grade2Data?.maxGradeNumeric ?? 0) - } - } - } - - private var minGrade: Int { - data.map { $0.maxGradeNumeric }.min() ?? 0 - } - - private var maxGrade: Int { - data.map { $0.maxGradeNumeric }.max() ?? 1 - } - - private var gradeRange: Int { - max(maxGrade - minGrade, 1) - } - - var body: some View { - GeometryReader { geometry in - let chartWidth = geometry.size.width - 40 - let chartHeight = geometry.size.height - 40 - - if data.isEmpty { - Rectangle() - .fill(.clear) - .overlay( - Text("No data") - .foregroundColor(.secondary) - ) - } else { - - HStack { - // Y-axis labels - VStack { - ForEach(0.. 1 { - Path { path in - for (index, point) in data.enumerated() { - let x = CGFloat(index) * chartWidth / CGFloat(data.count - 1) - let normalizedY = - CGFloat(point.maxGradeNumeric - minGrade) - / CGFloat(gradeRange) - let y = chartHeight - (normalizedY * chartHeight) - - if index == 0 { - path.move(to: CGPoint(x: x, y: y)) - } else { - path.addLine(to: CGPoint(x: x, y: y)) - } - } - } - .stroke(.blue, lineWidth: 2) - } - - // Data points - ForEach(data.indices, id: \.self) { index in - let point = data[index] - let x = - data.count == 1 - ? chartWidth / 2 - : CGFloat(index) * chartWidth / CGFloat(data.count - 1) - let normalizedY = - CGFloat(point.maxGradeNumeric - minGrade) / CGFloat(gradeRange) - let y = chartHeight - (normalizedY * chartHeight) - - Circle() - .fill(.blue) - .frame(width: 8, height: 8) - .position(x: x, y: y) - .overlay( - Circle() - .stroke(.white, lineWidth: 2) - .frame(width: 8, height: 8) - .position(x: x, y: y) - ) - } - } - .frame(width: chartWidth, height: chartHeight) - } - } - } - .padding() - } -} - -struct ProgressDataPoint { - let date: Date - let maxGrade: String - let maxGradeNumeric: Int - let climbType: ClimbType - let difficultySystem: DifficultySystem -} - #Preview { AnalyticsView() .environmentObject(ClimbingDataManager.preview)