// // ProblemDetailView.swift // OpenClimb // // Created by OpenClimb on 2025-01-17. // import SwiftUI struct ProblemDetailView: View { let problemId: UUID @EnvironmentObject var dataManager: ClimbingDataManager @Environment(\.dismiss) private var dismiss @State private var showingDeleteAlert = false @State private var showingImageViewer = false @State private var selectedImageIndex = 0 @State private var showingEditProblem = false private var problem: Problem? { dataManager.problem(withId: problemId) } private var gym: Gym? { guard let problem = problem else { return nil } return dataManager.gym(withId: problem.gymId) } private var attempts: [Attempt] { dataManager.attempts(forProblem: problemId) } private var successfulAttempts: [Attempt] { attempts.filter { $0.result.isSuccessful } } private var attemptsWithSessions: [(Attempt, ClimbSession)] { attempts.compactMap { attempt in guard let session = dataManager.session(withId: attempt.sessionId) else { return nil } return (attempt, session) }.sorted { $0.1.date > $1.1.date } } var body: some View { ScrollView { LazyVStack(spacing: 20) { if let problem = problem, let gym = gym { ProblemHeaderCard(problem: problem, gym: gym) ProgressSummaryCard( totalAttempts: attempts.count, successfulAttempts: successfulAttempts.count, firstSuccess: firstSuccessInfo ) if !problem.imagePaths.isEmpty { PhotosSection(imagePaths: problem.imagePaths) } AttemptHistorySection(attemptsWithSessions: attemptsWithSessions) } else { Text("Problem not found") .foregroundColor(.secondary) } } .padding() } .navigationTitle("Problem Details") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { if problem != nil { Menu { Button("Edit Problem") { showingEditProblem = true } Button(role: .destructive) { showingDeleteAlert = true } label: { Label("Delete Problem", systemImage: "trash") } } label: { Image(systemName: "ellipsis.circle") } } } } .alert("Delete Problem", isPresented: $showingDeleteAlert) { Button("Cancel", role: .cancel) {} Button("Delete", role: .destructive) { if let problem = problem { dataManager.deleteProblem(problem) dismiss() } } } message: { Text( "Are you sure you want to delete this problem? This will also delete all attempts associated with this problem." ) } .sheet(isPresented: $showingEditProblem) { if let problem = problem { AddEditProblemView(problemId: problem.id) } } .sheet(isPresented: $showingImageViewer) { if let problem = problem, !problem.imagePaths.isEmpty { ImageViewerView( imagePaths: problem.imagePaths, initialIndex: selectedImageIndex ) } } } private var firstSuccessInfo: (date: Date, result: AttemptResult)? { guard let firstSuccess = successfulAttempts.min(by: { attempt1, attempt2 in let session1 = dataManager.session(withId: attempt1.sessionId) let session2 = dataManager.session(withId: attempt2.sessionId) return session1?.date ?? Date() < session2?.date ?? Date() }) else { return nil } let session = dataManager.session(withId: firstSuccess.sessionId) return (date: session?.date ?? Date(), result: firstSuccess.result) } } struct ProblemHeaderCard: View { let problem: Problem let gym: Gym var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { VStack(alignment: .leading, spacing: 8) { Text(problem.name ?? "Unnamed Problem") .font(.title) .fontWeight(.bold) Text(gym.name) .font(.title2) .foregroundColor(.secondary) if let location = problem.location { Text(location) .font(.subheadline) .foregroundColor(.secondary) } } Spacer() VStack(alignment: .trailing, spacing: 8) { Text(problem.difficulty.grade) .font(.title) .fontWeight(.bold) .foregroundColor(.blue) Text(problem.climbType.displayName) .font(.subheadline) .foregroundColor(.secondary) Text(problem.difficulty.system.displayName) .font(.caption) .foregroundColor(.secondary) } } if let description = problem.description, !description.isEmpty { Text(description) .font(.body) } if let setter = problem.setter, !setter.isEmpty { Text("Set by: \(setter)") .font(.subheadline) .foregroundColor(.secondary) } if !problem.tags.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(problem.tags, id: \.self) { tag in Text(tag) .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 8) .fill(.blue.opacity(0.1)) ) .foregroundColor(.blue) } } .padding(.horizontal, 1) } } if let notes = problem.notes, !notes.isEmpty { Text(notes) .font(.caption) .foregroundColor(.secondary) .padding(.top, 4) } if !problem.isActive { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text("Inactive Problem") .font(.subheadline) .fontWeight(.medium) .foregroundColor(.orange) } .padding(.horizontal, 12) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 8) .fill(.orange.opacity(0.1)) ) } } .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) } } struct ProgressSummaryCard: View { let totalAttempts: Int let successfulAttempts: Int let firstSuccess: (date: Date, result: AttemptResult)? var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Progress Summary") .font(.title2) .fontWeight(.bold) if totalAttempts == 0 { Text("No attempts recorded yet") .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding() } else { HStack { StatItem(label: "Total Attempts", value: "\(totalAttempts)") StatItem(label: "Successful", value: "\(successfulAttempts)") } if let firstSuccess = firstSuccess { VStack(alignment: .leading, spacing: 4) { Text("First Success") .font(.subheadline) .fontWeight(.medium) Text( "\(formatDate(firstSuccess.date)) (\(firstSuccess.result.displayName))" ) .font(.subheadline) .foregroundColor(.blue) } .padding(.top, 8) } } } .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) } private func formatDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium return formatter.string(from: date) } } struct PhotosSection: View { let imagePaths: [String] @State private var showingImageViewer = false @State private var selectedImageIndex = 0 var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Photos") .font(.title2) .fontWeight(.bold) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(imagePaths.indices, id: \.self) { index in AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { RoundedRectangle(cornerRadius: 12) .fill(.gray.opacity(0.3)) } .frame(width: 120, height: 120) .clipped() .cornerRadius(12) .onTapGesture { selectedImageIndex = index showingImageViewer = true } } } .padding(.horizontal, 1) } } .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) .sheet(isPresented: $showingImageViewer) { ImageViewerView( imagePaths: imagePaths, initialIndex: selectedImageIndex ) } } } struct AttemptHistorySection: View { let attemptsWithSessions: [(Attempt, ClimbSession)] @EnvironmentObject var dataManager: ClimbingDataManager var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Attempt History (\(attemptsWithSessions.count))") .font(.title2) .fontWeight(.bold) if attemptsWithSessions.isEmpty { VStack(spacing: 12) { Image(systemName: "hand.raised.slash") .font(.title) .foregroundColor(.secondary) Text("No attempts yet") .font(.headline) .foregroundColor(.secondary) Text("Start a session and track your attempts on this problem!") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) } else { LazyVStack(spacing: 12) { ForEach(attemptsWithSessions.indices, id: \.self) { index in let (attempt, session) = attemptsWithSessions[index] AttemptHistoryCard(attempt: attempt, session: session) } } } } } } struct AttemptHistoryCard: View { let attempt: Attempt let session: ClimbSession @EnvironmentObject var dataManager: ClimbingDataManager private var gym: Gym? { dataManager.gym(withId: session.gymId) } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { Text(formatDate(session.date)) .font(.headline) .fontWeight(.semibold) if let gym = gym { Text(gym.name) .font(.subheadline) .foregroundColor(.secondary) } } Spacer() AttemptResultBadge(result: attempt.result) } if let notes = attempt.notes, !notes.isEmpty { Text(notes) .font(.subheadline) .foregroundColor(.secondary) } if let highestHold = attempt.highestHold, !highestHold.isEmpty { Text("Highest hold: \(highestHold)") .font(.caption) .foregroundColor(.secondary) } } .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(.ultraThinMaterial) ) } private func formatDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium return formatter.string(from: date) } } struct ImageViewerView: View { let imagePaths: [String] let initialIndex: Int @Environment(\.dismiss) private var dismiss @State private var currentIndex: Int init(imagePaths: [String], initialIndex: Int) { self.imagePaths = imagePaths self.initialIndex = initialIndex self._currentIndex = State(initialValue: initialIndex) } var body: some View { NavigationView { TabView(selection: $currentIndex) { ForEach(imagePaths.indices, id: \.self) { index in AsyncImage(url: URL(fileURLWithPath: imagePaths[index])) { image in image .resizable() .aspectRatio(contentMode: .fit) } placeholder: { ProgressView() } .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .always)) .navigationTitle("Photo \(currentIndex + 1) of \(imagePaths.count)") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } } } } } } #Preview { NavigationView { ProblemDetailView(problemId: UUID()) .environmentObject(ClimbingDataManager.preview) } }