Files
Ascently/ios/Ascently/Views/AddEdit/AddEditProblemView.swift
2026-02-02 09:47:17 -07:00

436 lines
15 KiB
Swift

import SwiftUI
import PhotosUI
import UniformTypeIdentifiers
struct AddEditProblemView: View {
let problemId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss
@State private var selectedGym: Gym?
@State private var name = ""
@State private var description = ""
@State private var selectedClimbType: ClimbType
@State private var selectedDifficultySystem: DifficultySystem
@State private var difficultyGrade = ""
@State private var availableDifficultySystems: [DifficultySystem] = []
@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 imageData: [Data] = []
@State private var showingPhotoOptions = false
@State private var showingCamera = false
@State private var showingImagePicker = false
@State private var imageSource: UIImagePickerController.SourceType = .photoLibrary
@State private var isEditing = false
@State private var showingGradeError = false
@State private var isLoaded = false
@State private var originalImages: [(path: String, data: Data)] = []
private var existingProblem: Problem? {
guard let problemId = problemId else { return nil }
return dataManager.problem(withId: problemId)
}
private var existingProblemGym: Gym? {
guard let problem = existingProblem else { return nil }
return dataManager.gym(withId: problem.gymId)
}
private var gymId: UUID? {
return selectedGym?.id ?? existingProblemGym?.id
}
init(problemId: UUID? = nil) {
self.problemId = problemId
self._selectedClimbType = State(initialValue: .boulder)
self._selectedDifficultySystem = State(initialValue: .vScale)
}
var body: some View {
NavigationStack {
Form {
GymSelectionSection()
BasicInfoSection()
PhotosSection()
ClimbTypeSection()
DifficultySection()
LocationDetailsSection()
AdditionalInfoSection()
}
.navigationTitle(isEditing ? "Edit Problem" : "New Problem")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
if canSave {
saveProblem()
} else {
showingGradeError = true
}
}
.disabled(!canSave && !showingGradeError)
}
}
}
.onAppear {
setupInitialClimbType()
loadExistingProblem()
DispatchQueue.main.async {
isLoaded = true
}
}
.sheet(isPresented: $showingPhotoOptions) {
PhotoOptionSheet(
selectedPhotos: .constant([]),
imageData: $imageData,
maxImages: 5,
onCameraSelected: {
showingCamera = true
},
onPhotoLibrarySelected: {
showingImagePicker = true
},
onDismiss: {
showingPhotoOptions = false
}
)
}
.sheet(isPresented: $showingCamera) {
CameraImagePicker { image in
if let data = image.jpegData(compressionQuality: 0.8) {
imageData.append(data)
}
}
}
.sheet(isPresented: $showingImagePicker) {
ImagePicker(
selectedImages: Binding(
get: { imageData },
set: { imageData = $0 }
),
sourceType: imageSource,
selectionLimit: 5
)
}
}
@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
gymRow(gym: gym, isSelected: selectedGym?.id == gym.id)
.onTapGesture {
selectedGym = gym
}
}
}
}
}
private func gymRow(gym: Gym, isSelected: Bool) -> some View {
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 isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(themeManager.accentColor)
}
}
.contentShape(Rectangle())
}
@ViewBuilder
private func BasicInfoSection() -> some View {
Section("Problem Details") {
TextField("Problem Name (Optional)", text: $name)
TextField("Description (Optional)", text: $description, axis: .vertical)
.lineLimit(3...6)
}
}
@ViewBuilder
private func PhotosSection() -> some View {
Section("Photos (Optional)") {
Button(action: {
showingPhotoOptions = true
}) {
HStack {
Image(systemName: "camera.fill")
.foregroundColor(themeManager.accentColor)
VStack(alignment: .leading) {
Text("Add Photos")
.foregroundColor(themeManager.accentColor)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) {
ZStack(alignment: .topTrailing) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
Button(action: {
imageData.remove(at: index)
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.white)
.background(Circle().fill(Color.black.opacity(0.5)))
}
.padding(4)
}
}
}
}
.padding(.vertical, 4)
}
}
}
}
@ViewBuilder
private func ClimbTypeSection() -> some View {
Section("Climb Type") {
ForEach(ClimbType.allCases, id: \.self) { type in
HStack {
Text(type.displayName)
Spacer()
if selectedClimbType == type {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(themeManager.accentColor)
} else {
Image(systemName: "circle")
.foregroundColor(.secondary)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedClimbType = type
}
}
}
}
@ViewBuilder
private func DifficultySection() -> some View {
Section("Difficulty") {
Picker("Difficulty System", selection: $selectedDifficultySystem) {
ForEach(DifficultySystem.systemsForClimbType(selectedClimbType), id: \.self) { system in
Text(system.displayName).tag(system)
}
}
.onChange(of: selectedDifficultySystem) { oldValue, newValue in
if isLoaded {
difficultyGrade = ""
}
}
if selectedDifficultySystem == .custom {
HStack {
Text("Grade")
Spacer()
TextField("Numbers only", text: $difficultyGrade)
.multilineTextAlignment(.trailing)
.keyboardType(.numberPad)
}
} else {
Picker("Grade", selection: $difficultyGrade) {
if difficultyGrade.isEmpty {
Text("Select Grade").tag("")
}
ForEach(selectedDifficultySystem.availableGrades, id: \.self) { grade in
Text(grade).tag(grade)
}
}
.pickerStyle(.menu)
}
if showingGradeError && difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Please select a grade to continue")
.font(.caption)
.foregroundColor(.red)
.italic()
}
}
}
@ViewBuilder
private func LocationDetailsSection() -> some View {
Section("Location & Details") {
TextField("e.g., 'Cave area', 'Wall 3'", text: $location)
DatePicker("Date Set", selection: $dateSet, displayedComponents: .date)
}
}
@ViewBuilder
private func AdditionalInfoSection() -> some View {
Section("Tags (Optional)") {
TextField("e.g., crimpy, dynamic (comma-separated)", text: $tags)
}
Section("Additional Information") {
TextField("Notes (Optional)", text: $notes, axis: .vertical)
.lineLimit(3...6)
Toggle("Problem is currently active", isOn: $isActive)
}
}
private var canSave: Bool {
selectedGym != nil && !difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func setupInitialClimbType() {
if let gymId = gymId {
selectedGym = dataManager.gym(withId: gymId)
}
if selectedGym == nil && !dataManager.gyms.isEmpty {
selectedGym = dataManager.gyms.first
}
}
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
location = problem.location ?? ""
tags = problem.tags.joined(separator: ", ")
notes = problem.notes ?? ""
isActive = problem.isActive
if let date = problem.dateSet {
dateSet = date
}
imagePaths = problem.imagePaths
imageData = []
originalImages = []
for imagePath in problem.imagePaths {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath) {
imageData.append(data)
originalImages.append((path: imagePath, data: data))
}
}
}
}
private func saveProblem() {
guard let gym = selectedGym else { return }
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = description.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 }
var finalPaths: [String] = []
var preservedPaths: Set<String> = []
for data in imageData {
if let existing = originalImages.first(where: { $0.data == data }) {
finalPaths.append(existing.path)
preservedPaths.insert(existing.path)
} else {
if let newPath = ImageManager.shared.saveImageData(data) {
finalPaths.append(newPath)
}
}
}
for original in originalImages {
if !preservedPaths.contains(original.path) {
_ = ImageManager.shared.deleteImage(atPath: original.path)
}
}
if isEditing, let problem = existingProblem {
let updatedProblem = problem.updated(
name: trimmedName.isEmpty ? nil : trimmedName,
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType,
difficulty: DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade),
tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: finalPaths,
isActive: isActive,
dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.updateProblem(updatedProblem)
dismiss()
} else {
let problem = Problem(
gymId: gym.id,
name: trimmedName.isEmpty ? nil : trimmedName,
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType,
difficulty: DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade),
tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: finalPaths,
dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.addProblem(problem)
dismiss()
}
}
}
#Preview {
AddEditProblemView()
.environmentObject(ClimbingDataManager.preview)
}