import PhotosUI 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 @State private var selectedPhotos: [PhotosPickerItem] = [] @State private var imageData: [Data] = [] enum SheetType: Identifiable { case photoOptions case camera var id: Int { switch self { case .photoOptions: return 0 case .camera: return 1 } } } @State private var activeSheet: SheetType? @State private var showPhotoPicker = false @State private var isPhotoPickerActionPending = false 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 { NavigationStack { 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) { updateDifficultySystem() } .onChange(of: selectedDifficultySystem) { resetGradeIfNeeded() } .onChange(of: selectedPhotos) { Task { await loadSelectedPhotos() } } .photosPicker( isPresented: $showPhotoPicker, selection: $selectedPhotos, maxSelectionCount: 5 - imageData.count, matching: .images ) .sheet( item: $activeSheet, onDismiss: { if isPhotoPickerActionPending { showPhotoPicker = true isPhotoPickerActionPending = false } } ) { sheetType in switch sheetType { case .photoOptions: PhotoOptionSheet( selectedPhotos: $selectedPhotos, imageData: $imageData, maxImages: 5, onCameraSelected: { activeSheet = .camera }, onPhotoLibrarySelected: { isPhotoPickerActionPending = true }, onDismiss: { activeSheet = nil } ) case .camera: CameraImagePicker( isPresented: Binding( get: { activeSheet == .camera }, set: { if !$0 { activeSheet = nil } } ) ) { capturedImage in if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) { imageData.append(jpegData) } } } } } @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 { LazyVGrid( columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), spacing: 8 ) { ForEach(activeProblems, 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 selectedPhotos = [] imageData = [] } .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) } } } } Section("Photos (Optional)") { Button(action: { activeSheet = .photoOptions }) { HStack { Image(systemName: "camera.fill") .foregroundColor(.blue) .font(.title2) VStack(alignment: .leading, spacing: 2) { Text("Add Photos") .font(.headline) .foregroundColor(.blue) Text("\(imageData.count) of 5 photos added") .font(.caption) .foregroundColor(.secondary) } Spacer() Image(systemName: "chevron.right") .foregroundColor(.secondary) .font(.caption) } .padding(.vertical, 4) } .disabled(imageData.count >= 5) 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) }) { 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 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 loadSelectedPhotos() async { var newImageData: [Data] = [] for item in selectedPhotos { if let data = try? await item.loadTransferable(type: Data.self) { newImageData.append(data) } } await MainActor.run { imageData.append(contentsOf: newImageData) selectedPhotos.removeAll() } } 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, imagePaths: [] ) dataManager.addProblem(newProblem) if !imageData.isEmpty { var imagePaths: [String] = [] for (index, data) in imageData.enumerated() { let deterministicName = ImageNamingUtils.generateImageFilename( problemId: newProblem.id.uuidString, imageIndex: index) if let relativePath = ImageManager.shared.saveImageData( data, withName: deterministicName) { imagePaths.append(relativePath) } } if !imagePaths.isEmpty { let updatedProblem = newProblem.updated(imagePaths: imagePaths) dataManager.updateProblem(updatedProblem) } } 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) } // Clear photo states after saving selectedPhotos = [] imageData = [] 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 ProblemSelectionCard: View { let problem: Problem let isSelected: Bool let action: () -> Void @State private var showingExpandedView = false var body: some View { VStack(spacing: 8) { // Image section ZStack { if let firstImagePath = problem.imagePaths.first { ProblemSelectionImageView(imagePath: firstImagePath) } else { RoundedRectangle(cornerRadius: 8) .fill(.gray.opacity(0.2)) .frame(height: 80) .overlay { Image(systemName: "mountain.2.fill") .foregroundColor(.gray) .font(.title2) } } // Selection indicator VStack { HStack { Spacer() if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundColor(.white) .background(Circle().fill(.blue)) .font(.title3) } } Spacer() } .padding(6) // Multiple images indicator if problem.imagePaths.count > 1 { VStack { Spacer() HStack { Spacer() Text("+\(problem.imagePaths.count - 1)") .font(.caption2) .fontWeight(.bold) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 4) .fill(.black.opacity(0.6)) ) } } .padding(6) } } // Problem info VStack(alignment: .leading, spacing: 4) { Text(problem.name ?? "Unnamed") .font(.caption) .fontWeight(.medium) .lineLimit(1) Text(problem.difficulty.grade) .font(.caption2) .fontWeight(.bold) .foregroundColor(.blue) if let location = problem.location { Text(location) .font(.caption2) .foregroundColor(.secondary) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) } .padding(8) .background( RoundedRectangle(cornerRadius: 12) .fill(isSelected ? .blue.opacity(0.1) : .gray.opacity(0.05)) .stroke(isSelected ? .blue : .clear, lineWidth: 2) ) .contentShape(Rectangle()) .onTapGesture { if isSelected { showingExpandedView = true } else { action() } } .sheet(isPresented: $showingExpandedView) { ProblemExpandedView(problem: problem) } } } struct ProblemExpandedView: View { let problem: Problem @Environment(\.dismiss) private var dismiss @State private var selectedImageIndex = 0 var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { // Images if !problem.imagePaths.isEmpty { TabView(selection: $selectedImageIndex) { ForEach(problem.imagePaths.indices, id: \.self) { index in ProblemSelectionImageFullView(imagePath: problem.imagePaths[index]) .tag(index) } } .frame(height: 250) .tabViewStyle(.page(indexDisplayMode: .always)) } // Problem details VStack(alignment: .leading, spacing: 12) { Text(problem.name ?? "Unnamed Problem") .font(.title2) .fontWeight(.bold) HStack { Text(problem.difficulty.grade) .font(.title3) .fontWeight(.bold) .foregroundColor(.blue) Text(problem.climbType.displayName) .font(.subheadline) .foregroundColor(.secondary) } if let location = problem.location, !location.isEmpty { Label(location, systemImage: "location") .font(.subheadline) .foregroundColor(.secondary) } if let description = problem.description, !description.isEmpty { Text(description) .font(.body) } 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) } } } .padding(.horizontal) } } .navigationTitle("Problem Details") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } } } } } } 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 @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 @State private var selectedPhotos: [PhotosPickerItem] = [] @State private var imageData: [Data] = [] enum SheetType: Identifiable { case photoOptions case camera var id: Int { switch self { case .photoOptions: return 0 case .camera: return 1 } } } @State private var activeSheet: SheetType? @State private var showPhotoPicker = false @State private var isPhotoPickerActionPending = false private var availableProblems: [Problem] { 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) { 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 { NavigationStack { Form { if !showingCreateProblem { ProblemSelectionSection() } else { CreateProblemSection() } AttemptDetailsSection() } .navigationTitle("Edit Attempt") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { Button("Update") { updateAttempt() } .disabled(!canSave) } } } .onAppear { selectedProblem = dataManager.problem(withId: attempt.problemId) setupInitialValues() } .onChange(of: selectedClimbType) { updateDifficultySystem() } .onChange(of: selectedDifficultySystem) { resetGradeIfNeeded() } .onChange(of: selectedPhotos) { Task { await loadSelectedPhotos() } } .photosPicker( isPresented: $showPhotoPicker, selection: $selectedPhotos, maxSelectionCount: 5 - imageData.count, matching: .images ) .sheet( item: $activeSheet, onDismiss: { if isPhotoPickerActionPending { showPhotoPicker = true isPhotoPickerActionPending = false } } ) { sheetType in switch sheetType { case .photoOptions: PhotoOptionSheet( selectedPhotos: $selectedPhotos, imageData: $imageData, maxImages: 5, onCameraSelected: { activeSheet = .camera }, onPhotoLibrarySelected: { isPhotoPickerActionPending = true }, onDismiss: { activeSheet = nil } ) case .camera: CameraImagePicker( isPresented: Binding( get: { activeSheet == .camera }, set: { if !$0 { activeSheet = nil } } ) ) { capturedImage in if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) { imageData.append(jpegData) } } } } } @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 selectedPhotos = [] imageData = [] } .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) } } } } Section("Photos (Optional)") { Button(action: { activeSheet = .photoOptions }) { HStack { Image(systemName: "camera.fill") .foregroundColor(.blue) .font(.title2) VStack(alignment: .leading, spacing: 2) { Text("Add Photos") .font(.headline) .foregroundColor(.blue) Text("\(imageData.count) of 5 photos added") .font(.caption) .foregroundColor(.secondary) } Spacer() Image(systemName: "chevron.right") .foregroundColor(.secondary) .font(.caption) } .padding(.vertical, 4) } .disabled(imageData.count >= 5) 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) }) { 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 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 loadSelectedPhotos() async { var newImageData: [Data] = [] for item in selectedPhotos { if let data = try? await item.loadTransferable(type: Data.self) { newImageData.append(data) } } await MainActor.run { imageData.append(contentsOf: newImageData) selectedPhotos.removeAll() } } private func updateAttempt() { if showingCreateProblem { guard let gym = gym else { return } let difficulty = DifficultyGrade( system: selectedDifficultySystem, grade: newProblemGrade) let newProblem = Problem( gymId: gym.id, name: newProblemName.isEmpty ? nil : newProblemName, climbType: selectedClimbType, difficulty: difficulty, imagePaths: [] ) dataManager.addProblem(newProblem) if !imageData.isEmpty { var imagePaths: [String] = [] for (index, data) in imageData.enumerated() { let deterministicName = ImageNamingUtils.generateImageFilename( problemId: newProblem.id.uuidString, imageIndex: index) if let relativePath = ImageManager.shared.saveImageData( data, withName: deterministicName) { imagePaths.append(relativePath) } } if !imagePaths.isEmpty { let updatedProblem = newProblem.updated(imagePaths: imagePaths) dataManager.updateProblem(updatedProblem) } } 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) } // Clear photo states after saving selectedPhotos = [] imageData = [] dismiss() } } #Preview { AddAttemptView( session: ClimbSession(gymId: UUID()), gym: Gym( name: "Sample Gym", supportedClimbTypes: [.boulder], difficultySystems: [.vScale] ) ) .environmentObject(ClimbingDataManager.preview) } struct ProblemSelectionImageView: View { let imagePath: String @State private var uiImage: UIImage? @State private var isLoading = true @State private var hasFailed = false var body: some View { Group { if let uiImage = uiImage { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fill) .frame(height: 80) .clipped() .cornerRadius(8) } else if hasFailed { RoundedRectangle(cornerRadius: 8) .fill(.gray.opacity(0.2)) .frame(height: 80) .overlay { Image(systemName: "photo") .foregroundColor(.gray) .font(.title3) } } else { RoundedRectangle(cornerRadius: 8) .fill(.gray.opacity(0.3)) .frame(height: 80) .overlay { ProgressView() .scaleEffect(0.8) } } } .onAppear { loadImage() } } private func loadImage() { guard !imagePath.isEmpty else { hasFailed = true isLoading = false return } Task { let data = await MainActor.run { ImageManager.shared.loadImageData(fromPath: imagePath) } if let data = data, let image = UIImage(data: data) { await MainActor.run { self.uiImage = image self.isLoading = false } } else { await MainActor.run { self.hasFailed = true self.isLoading = false } } } } } struct ProblemSelectionImageFullView: View { let imagePath: String @State private var uiImage: UIImage? @State private var isLoading = true @State private var hasFailed = false var body: some View { Group { if let uiImage = uiImage { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) } else if hasFailed { RoundedRectangle(cornerRadius: 12) .fill(.gray.opacity(0.2)) .frame(height: 250) .overlay { VStack(spacing: 8) { Image(systemName: "photo") .foregroundColor(.gray) .font(.largeTitle) Text("Image not available") .foregroundColor(.gray) } } } else { RoundedRectangle(cornerRadius: 12) .fill(.gray.opacity(0.3)) .frame(height: 250) .overlay { ProgressView() } } } .onAppear { loadImage() } } private func loadImage() { guard !imagePath.isEmpty else { hasFailed = true isLoading = false return } DispatchQueue.global(qos: .userInitiated).async { if let data = ImageManager.shared.loadImageData(fromPath: imagePath), let image = UIImage(data: data) { DispatchQueue.main.async { self.uiImage = image self.isLoading = false } } else { DispatchQueue.main.async { self.hasFailed = true self.isLoading = false } } } } }