1289 lines
44 KiB
Swift
1289 lines
44 KiB
Swift
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] = []
|
|
|
|
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 {
|
|
NavigationView {
|
|
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()
|
|
}
|
|
}
|
|
|
|
@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)") {
|
|
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)
|
|
}
|
|
.onChange(of: selectedPhotos) { _, _ in
|
|
Task {
|
|
await loadSelectedPhotos()
|
|
}
|
|
}
|
|
|
|
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 saveAttempt() {
|
|
if showingCreateProblem {
|
|
let difficulty = DifficultyGrade(
|
|
system: selectedDifficultySystem, grade: newProblemGrade)
|
|
|
|
// Save images and get paths
|
|
var imagePaths: [String] = []
|
|
for data in imageData {
|
|
if let relativePath = ImageManager.shared.saveImageData(data) {
|
|
imagePaths.append(relativePath)
|
|
}
|
|
}
|
|
|
|
let newProblem = Problem(
|
|
gymId: gym.id,
|
|
name: newProblemName.isEmpty ? nil : newProblemName,
|
|
climbType: selectedClimbType,
|
|
difficulty: difficulty,
|
|
imagePaths: imagePaths
|
|
)
|
|
|
|
dataManager.addProblem(newProblem)
|
|
|
|
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()
|
|
}
|
|
|
|
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 = newImageData
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
NavigationView {
|
|
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] = []
|
|
|
|
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 {
|
|
NavigationView {
|
|
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()
|
|
}
|
|
}
|
|
|
|
@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)") {
|
|
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)
|
|
}
|
|
.onChange(of: selectedPhotos) { _, _ in
|
|
Task {
|
|
await loadSelectedPhotos()
|
|
}
|
|
}
|
|
|
|
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 updateAttempt() {
|
|
if showingCreateProblem {
|
|
guard let gym = gym else { return }
|
|
|
|
let difficulty = DifficultyGrade(
|
|
system: selectedDifficultySystem, grade: newProblemGrade)
|
|
|
|
// Save images and get paths
|
|
var imagePaths: [String] = []
|
|
for data in imageData {
|
|
if let relativePath = ImageManager.shared.saveImageData(data) {
|
|
imagePaths.append(relativePath)
|
|
}
|
|
}
|
|
|
|
let newProblem = Problem(
|
|
gymId: gym.id,
|
|
name: newProblemName.isEmpty ? nil : newProblemName,
|
|
climbType: selectedClimbType,
|
|
difficulty: difficulty,
|
|
imagePaths: imagePaths
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
// Clear photo states after saving
|
|
selectedPhotos = []
|
|
imageData = []
|
|
|
|
dismiss()
|
|
}
|
|
|
|
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 = newImageData
|
|
}
|
|
}
|
|
}
|
|
|
|
#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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|