533 lines
19 KiB
Swift
533 lines
19 KiB
Swift
import PhotosUI
|
|
import SwiftUI
|
|
|
|
struct AddEditProblemView: View {
|
|
let problemId: UUID?
|
|
let gymId: UUID?
|
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var selectedGym: Gym?
|
|
@State private var name = ""
|
|
@State private var description = ""
|
|
@State private var selectedClimbType: ClimbType = .boulder
|
|
@State private var selectedDifficultySystem: DifficultySystem = .vScale
|
|
@State private var difficultyGrade = ""
|
|
@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 selectedPhotos: [PhotosPickerItem] = []
|
|
@State private var imageData: [Data] = []
|
|
@State private var isEditing = false
|
|
|
|
private var existingProblem: Problem? {
|
|
guard let problemId = problemId else { return nil }
|
|
return dataManager.problem(withId: problemId)
|
|
}
|
|
|
|
private var availableClimbTypes: [ClimbType] {
|
|
selectedGym?.supportedClimbTypes ?? ClimbType.allCases
|
|
}
|
|
|
|
var availableDifficultySystems: [DifficultySystem] {
|
|
guard let gym = selectedGym else {
|
|
return DifficultySystem.systemsForClimbType(selectedClimbType)
|
|
}
|
|
|
|
let compatibleSystems = DifficultySystem.systemsForClimbType(selectedClimbType)
|
|
let gymSupportedSystems = gym.difficultySystems.filter { system in
|
|
compatibleSystems.contains(system)
|
|
}
|
|
|
|
return gymSupportedSystems.isEmpty ? compatibleSystems : gymSupportedSystems
|
|
}
|
|
|
|
private var availableGrades: [String] {
|
|
selectedDifficultySystem.availableGrades
|
|
}
|
|
|
|
init(problemId: UUID? = nil, gymId: UUID? = nil) {
|
|
self.problemId = problemId
|
|
self.gymId = gymId
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
GymSelectionSection()
|
|
BasicInfoSection()
|
|
PhotosSection()
|
|
ClimbTypeSection()
|
|
DifficultySection()
|
|
LocationSection()
|
|
TagsSection()
|
|
AdditionalInfoSection()
|
|
}
|
|
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Save") {
|
|
saveProblem()
|
|
}
|
|
.disabled(!canSave)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
loadExistingProblem()
|
|
setupInitialGym()
|
|
}
|
|
.onChange(of: selectedGym) {
|
|
updateAvailableOptions()
|
|
}
|
|
.onChange(of: selectedClimbType) {
|
|
updateDifficultySystem()
|
|
}
|
|
.onChange(of: selectedDifficultySystem) {
|
|
resetGradeIfNeeded()
|
|
}
|
|
.onChange(of: selectedPhotos) {
|
|
Task {
|
|
await loadSelectedPhotos()
|
|
}
|
|
}
|
|
}
|
|
|
|
@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
|
|
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 selectedGym?.id == gym.id {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
selectedGym = gym
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func BasicInfoSection() -> some View {
|
|
Section("Problem Details") {
|
|
TextField("Problem Name (Optional)", text: $name)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Description (Optional)")
|
|
.font(.headline)
|
|
|
|
TextEditor(text: $description)
|
|
.frame(minHeight: 80)
|
|
.padding(8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.quaternary)
|
|
)
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func ClimbTypeSection() -> some View {
|
|
if selectedGym != nil {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func DifficultySection() -> some View {
|
|
Section("Difficulty") {
|
|
// Difficulty System
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Difficulty System")
|
|
.font(.headline)
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Grade Selection
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Grade (Required)")
|
|
.font(.headline)
|
|
|
|
if selectedDifficultySystem == .custom || availableGrades.isEmpty {
|
|
TextField("Enter custom grade (numbers only)", text: $difficultyGrade)
|
|
.textFieldStyle(.roundedBorder)
|
|
.keyboardType(.numberPad)
|
|
.onChange(of: difficultyGrade) {
|
|
// Filter out non-numeric characters
|
|
difficultyGrade = difficultyGrade.filter { $0.isNumber }
|
|
}
|
|
} else {
|
|
Menu {
|
|
if !difficultyGrade.isEmpty {
|
|
Button("Clear Selection") {
|
|
difficultyGrade = ""
|
|
}
|
|
|
|
Divider()
|
|
}
|
|
|
|
ForEach(availableGrades, id: \.self) { grade in
|
|
Button(grade) {
|
|
difficultyGrade = grade
|
|
}
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Text(difficultyGrade.isEmpty ? "Select Grade" : difficultyGrade)
|
|
.foregroundColor(difficultyGrade.isEmpty ? .secondary : .primary)
|
|
.fontWeight(difficultyGrade.isEmpty ? .regular : .semibold)
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.down")
|
|
.foregroundColor(.secondary)
|
|
.font(.caption)
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.gray.opacity(0.1))
|
|
.stroke(
|
|
difficultyGrade.isEmpty
|
|
? .red.opacity(0.5) : .gray.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
if difficultyGrade.isEmpty {
|
|
Text("Please select a grade to continue")
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
.italic()
|
|
} else {
|
|
Text("Selected: \(difficultyGrade)")
|
|
.font(.caption)
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func LocationSection() -> some View {
|
|
Section("Location & Details") {
|
|
TextField(
|
|
"Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'"))
|
|
|
|
DatePicker(
|
|
"Date Set",
|
|
selection: $dateSet,
|
|
displayedComponents: [.date]
|
|
)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func TagsSection() -> some View {
|
|
Section("Tags (Optional)") {
|
|
TextField("Tags", text: $tags, prompt: Text("e.g., crimpy, dynamic (comma-separated)"))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func PhotosSection() -> some View {
|
|
Section("Photos (Optional)") {
|
|
PhotosPicker(
|
|
selection: $selectedPhotos,
|
|
maxSelectionCount: 5,
|
|
matching: .images
|
|
) {
|
|
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)
|
|
}
|
|
|
|
if !imageData.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 12) {
|
|
ForEach(imageData.indices, id: \.self) { index in
|
|
if let uiImage = UIImage(data: imageData[index]) {
|
|
ZStack(alignment: .topTrailing) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: 80, height: 80)
|
|
.clipped()
|
|
.cornerRadius(8)
|
|
|
|
Button(action: {
|
|
imageData.remove(at: index)
|
|
if index < imagePaths.count {
|
|
imagePaths.remove(at: index)
|
|
}
|
|
}) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.red)
|
|
.background(Circle().fill(.white))
|
|
.font(.system(size: 18))
|
|
}
|
|
.offset(x: 4, y: -4)
|
|
}
|
|
.frame(width: 88, height: 88) // Extra space for button
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.gray.opacity(0.3))
|
|
.frame(width: 80, height: 80)
|
|
.overlay {
|
|
Image(systemName: "photo")
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 1)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func AdditionalInfoSection() -> some View {
|
|
Section("Additional Information") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Notes (Optional)")
|
|
.font(.headline)
|
|
|
|
TextEditor(text: $notes)
|
|
.frame(minHeight: 80)
|
|
.padding(8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.quaternary)
|
|
)
|
|
}
|
|
|
|
Toggle("Problem is currently active", isOn: $isActive)
|
|
}
|
|
}
|
|
|
|
private var canSave: Bool {
|
|
selectedGym != nil
|
|
&& !difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
private func setupInitialGym() {
|
|
if let gymId = gymId, selectedGym == nil {
|
|
selectedGym = dataManager.gym(withId: gymId)
|
|
}
|
|
}
|
|
|
|
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
|
|
imagePaths = problem.imagePaths
|
|
|
|
// Load image data for preview
|
|
imageData = []
|
|
for imagePath in problem.imagePaths {
|
|
if let data = ImageManager.shared.loadImageData(fromPath: imagePath) {
|
|
imageData.append(data)
|
|
}
|
|
}
|
|
|
|
if let dateSet = problem.dateSet {
|
|
self.dateSet = dateSet
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateAvailableOptions() {
|
|
guard let gym = selectedGym else { return }
|
|
|
|
// Auto-select climb type if there's only one available
|
|
if gym.supportedClimbTypes.count == 1, selectedClimbType != gym.supportedClimbTypes.first! {
|
|
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! {
|
|
selectedDifficultySystem = available.first!
|
|
}
|
|
}
|
|
|
|
private func resetGradeIfNeeded() {
|
|
let availableGrades = selectedDifficultySystem.availableGrades
|
|
if !availableGrades.isEmpty && !availableGrades.contains(difficultyGrade) {
|
|
difficultyGrade = ""
|
|
}
|
|
}
|
|
|
|
private func loadSelectedPhotos() async {
|
|
for item in selectedPhotos {
|
|
if let data = try? await item.loadTransferable(type: Data.self) {
|
|
// Use ImageManager to save image
|
|
if let relativePath = ImageManager.shared.saveImageData(data) {
|
|
imagePaths.append(relativePath)
|
|
imageData.append(data)
|
|
}
|
|
}
|
|
}
|
|
selectedPhotos.removeAll()
|
|
}
|
|
|
|
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 }
|
|
|
|
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
|
|
|
|
if isEditing, let problem = existingProblem {
|
|
let updatedProblem = problem.updated(
|
|
name: trimmedName.isEmpty ? nil : trimmedName,
|
|
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
|
climbType: selectedClimbType,
|
|
difficulty: difficulty,
|
|
|
|
tags: trimmedTags,
|
|
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
|
imagePaths: imagePaths,
|
|
isActive: isActive,
|
|
dateSet: dateSet,
|
|
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
|
)
|
|
dataManager.updateProblem(updatedProblem)
|
|
} else {
|
|
let newProblem = Problem(
|
|
gymId: gym.id,
|
|
name: trimmedName.isEmpty ? nil : trimmedName,
|
|
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
|
climbType: selectedClimbType,
|
|
difficulty: difficulty,
|
|
|
|
tags: trimmedTags,
|
|
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
|
imagePaths: imagePaths,
|
|
dateSet: dateSet,
|
|
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
|
)
|
|
dataManager.addProblem(newProblem)
|
|
}
|
|
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
AddEditProblemView()
|
|
.environmentObject(ClimbingDataManager.preview)
|
|
}
|