1.5.0 Initial run as iOS in a monorepo

This commit is contained in:
2025-09-12 22:35:14 -06:00
parent f106244e57
commit 7da1893748
127 changed files with 7062 additions and 1039 deletions

View File

@@ -0,0 +1,554 @@
//
// AddAttemptView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
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
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) { _ in
updateDifficultySystem()
}
.onChange(of: selectedDifficultySystem) { _ in
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 {
ForEach(activeProblems, id: \.id) { problem in
ProblemSelectionRow(
problem: problem,
isSelected: selectedProblem?.id == problem.id
) {
selectedProblem = problem
}
}
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
}
.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)", text: $newProblemGrade)
.keyboardType(.numberPad)
} 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)
}
}
}
}
}
@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)
let newProblem = Problem(
gymId: gym.id,
name: newProblemName.isEmpty ? nil : newProblemName,
climbType: selectedClimbType,
difficulty: difficulty
)
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)
}
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 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
private var availableProblems: [Problem] {
dataManager.problems.filter { $0.isActive }
}
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 {
Section("Problem") {
if availableProblems.isEmpty {
Text("No problems available")
.foregroundColor(.secondary)
} else {
ForEach(availableProblems, id: \.id) { problem in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
Text(
"\(problem.difficulty.system.displayName): \(problem.difficulty.grade)"
)
.font(.subheadline)
.foregroundColor(.blue)
}
Spacer()
if selectedProblem?.id == problem.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedProblem = problem
}
}
}
}
Section("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("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)
}
}
}
.navigationTitle("Edit Attempt")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Update") {
updateAttempt()
}
.disabled(selectedProblem == nil)
}
}
}
.onAppear {
selectedProblem = dataManager.problem(withId: attempt.problemId)
}
}
private func updateAttempt() {
guard let problem = selectedProblem else { return }
let updatedAttempt = attempt.updated(
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)
dismiss()
}
}
#Preview {
AddAttemptView(
session: ClimbSession(gymId: UUID()),
gym: Gym(
name: "Sample Gym",
supportedClimbTypes: [.boulder],
difficultySystems: [.vScale]
)
)
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,216 @@
//
// AddEditGymView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct AddEditGymView: View {
let gymId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var location = ""
@State private var notes = ""
@State private var selectedClimbTypes = Set<ClimbType>()
@State private var selectedDifficultySystems = Set<DifficultySystem>()
@State private var customDifficultyGrades: [String] = []
@State private var isEditing = false
private var existingGym: Gym? {
guard let gymId = gymId else { return nil }
return dataManager.gym(withId: gymId)
}
private var availableDifficultySystems: [DifficultySystem] {
if selectedClimbTypes.isEmpty {
return []
} else {
return selectedClimbTypes.flatMap { climbType in
DifficultySystem.systemsForClimbType(climbType)
}.removingDuplicates()
}
}
init(gymId: UUID? = nil) {
self.gymId = gymId
}
var body: some View {
NavigationView {
Form {
BasicInfoSection()
ClimbTypesSection()
DifficultySystemsSection()
NotesSection()
}
.navigationTitle(isEditing ? "Edit Gym" : "Add Gym")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveGym()
}
.disabled(!canSave)
}
}
}
.onAppear {
loadExistingGym()
}
.onChange(of: selectedClimbTypes) { _ in
updateAvailableDifficultySystems()
}
}
@ViewBuilder
private func BasicInfoSection() -> some View {
Section("Basic Information") {
TextField("Gym Name", text: $name)
TextField("Location (Optional)", text: $location)
}
}
@ViewBuilder
private func ClimbTypesSection() -> some View {
Section("Supported Climb Types") {
ForEach(ClimbType.allCases, id: \.self) { climbType in
HStack {
Text(climbType.displayName)
Spacer()
if selectedClimbTypes.contains(climbType) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
if selectedClimbTypes.contains(climbType) {
selectedClimbTypes.remove(climbType)
} else {
selectedClimbTypes.insert(climbType)
}
}
}
}
}
@ViewBuilder
private func DifficultySystemsSection() -> some View {
Section("Difficulty Systems") {
if selectedClimbTypes.isEmpty {
Text("Select climb types first to see available difficulty systems")
.foregroundColor(.secondary)
.font(.caption)
} else {
ForEach(availableDifficultySystems, id: \.self) { system in
HStack {
Text(system.displayName)
Spacer()
if selectedDifficultySystems.contains(system) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
if selectedDifficultySystems.contains(system) {
selectedDifficultySystems.remove(system)
} else {
selectedDifficultySystems.insert(system)
}
}
}
}
}
}
@ViewBuilder
private func NotesSection() -> some View {
Section("Notes (Optional)") {
TextEditor(text: $notes)
.frame(minHeight: 100)
}
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !selectedClimbTypes.isEmpty
&& !selectedDifficultySystems.isEmpty
}
private func loadExistingGym() {
if let gym = existingGym {
isEditing = true
name = gym.name
location = gym.location ?? ""
notes = gym.notes ?? ""
selectedClimbTypes = Set(gym.supportedClimbTypes)
selectedDifficultySystems = Set(gym.difficultySystems)
customDifficultyGrades = gym.customDifficultyGrades
}
}
private func updateAvailableDifficultySystems() {
// Remove selected systems that are no longer available
let availableSet = Set(availableDifficultySystems)
selectedDifficultySystems = selectedDifficultySystems.intersection(availableSet)
}
private func saveGym() {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
if isEditing, let gym = existingGym {
let updatedGym = gym.updated(
name: trimmedName,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
supportedClimbTypes: Array(selectedClimbTypes),
difficultySystems: Array(selectedDifficultySystems),
customDifficultyGrades: customDifficultyGrades,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.updateGym(updatedGym)
} else {
let newGym = Gym(
name: trimmedName,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
supportedClimbTypes: Array(selectedClimbTypes),
difficultySystems: Array(selectedDifficultySystems),
customDifficultyGrades: customDifficultyGrades,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.addGym(newGym)
}
dismiss()
}
}
extension Array where Element: Hashable {
func removingDuplicates() -> [Element] {
var seen = Set<Element>()
return filter { seen.insert($0).inserted }
}
}
#Preview {
AddEditGymView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,529 @@
//
// AddEditProblemView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
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 setter = ""
@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 {
NavigationView {
Form {
GymSelectionSection()
BasicInfoSection()
ClimbTypeSection()
DifficultySection()
LocationAndSetterSection()
TagsSection()
PhotosSection()
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) { _ in
updateAvailableOptions()
}
.onChange(of: selectedClimbType) { _ in
updateDifficultySystem()
}
.onChange(of: selectedDifficultySystem) { _ in
resetGradeIfNeeded()
}
.onChange(of: selectedPhotos) { _ in
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)
)
}
TextField("Route Setter (Optional)", text: $setter)
}
}
@ViewBuilder
private func ClimbTypeSection() -> some View {
if let gym = selectedGym {
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", text: $difficultyGrade)
.textFieldStyle(.roundedBorder)
} 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 LocationAndSetterSection() -> 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") {
PhotosPicker(
selection: $selectedPhotos,
maxSelectionCount: 5,
matching: .images
) {
HStack {
Image(systemName: "photo.on.rectangle.angled")
.foregroundColor(.blue)
Text("Add Photos (\(imageData.count)/5)")
Spacer()
}
}
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)
if index < imagePaths.count {
imagePaths.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 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
setter = problem.setter ?? ""
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 = try? Data(contentsOf: URL(fileURLWithPath: 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) {
// Save to app's documents directory
let documentsPath = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first!
let imageName = "photo_\(UUID().uuidString).jpg"
let imagePath = documentsPath.appendingPathComponent(imageName)
do {
try data.write(to: imagePath)
imagePaths.append(imagePath.path)
imageData.append(data)
} catch {
print("Failed to save image: \(error)")
}
}
}
selectedPhotos.removeAll()
}
private func saveProblem() {
guard let gym = selectedGym else { return }
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedSetter = setter.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,
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
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,
setter: trimmedSetter.isEmpty ? nil : trimmedSetter,
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)
}

View File

@@ -0,0 +1,143 @@
//
// AddEditSessionView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct AddEditSessionView: View {
let sessionId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var selectedGym: Gym?
@State private var sessionDate = Date()
@State private var notes = ""
@State private var isEditing = false
private var existingSession: ClimbSession? {
guard let sessionId = sessionId else { return nil }
return dataManager.session(withId: sessionId)
}
init(sessionId: UUID? = nil) {
self.sessionId = sessionId
}
var body: some View {
NavigationView {
Form {
GymSelectionSection()
SessionDetailsSection()
}
.navigationTitle(isEditing ? "Edit Session" : "New Session")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveSession()
}
.disabled(selectedGym == nil)
}
}
}
.onAppear {
loadExistingSession()
}
}
@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 SessionDetailsSection() -> some View {
Section("Session Details") {
DatePicker(
"Date",
selection: $sessionDate,
displayedComponents: [.date]
)
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextEditor(text: $notes)
.frame(minHeight: 100)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
}
}
private func loadExistingSession() {
if let session = existingSession {
isEditing = true
selectedGym = dataManager.gym(withId: session.gymId)
sessionDate = session.date
notes = session.notes ?? ""
}
}
private func saveSession() {
guard let gym = selectedGym else { return }
if isEditing, let session = existingSession {
let updatedSession = session.updated(notes: notes.isEmpty ? nil : notes)
dataManager.updateSession(updatedSession)
} else {
dataManager.startSession(gymId: gym.id, notes: notes.isEmpty ? nil : notes)
}
dismiss()
}
}
#Preview {
AddEditSessionView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,407 @@
//
// AnalyticsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct AnalyticsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
NavigationView {
ScrollView {
LazyVStack(spacing: 20) {
HeaderSection()
OverallStatsSection()
ProgressChartSection()
FavoriteGymSection()
RecentActivitySection()
}
.padding()
}
.navigationTitle("Analytics")
}
}
}
struct HeaderSection: View {
var body: some View {
HStack {
Image(systemName: "mountain.2.fill")
.font(.title)
.foregroundColor(.blue)
Text("Analytics")
.font(.title)
.fontWeight(.bold)
Spacer()
}
}
}
struct OverallStatsSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Overall Stats")
.font(.title2)
.fontWeight(.bold)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
StatCard(
title: "Sessions",
value: "\(dataManager.completedSessions().count)",
icon: "play.fill",
color: .blue
)
StatCard(
title: "Problems",
value: "\(dataManager.problems.count)",
icon: "star.fill",
color: .orange
)
StatCard(
title: "Attempts",
value: "\(dataManager.totalAttempts())",
icon: "hand.raised.fill",
color: .green
)
StatCard(
title: "Gyms",
value: "\(dataManager.gyms.count)",
icon: "location.fill",
color: .purple
)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct StatCard: View {
let title: String
let value: String
let icon: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(color)
Text(value)
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
)
}
}
struct ProgressChartSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var selectedSystem: DifficultySystem = .vScale
private var progressData: [ProgressDataPoint] {
calculateProgressOverTime()
}
private var usedSystems: [DifficultySystem] {
Set(progressData.map { $0.difficultySystem }).sorted { $0.rawValue < $1.rawValue }
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Progress Over Time")
.font(.title2)
.fontWeight(.bold)
Spacer()
if usedSystems.count > 1 {
Menu {
ForEach(usedSystems, id: \.self) { system in
Button(system.displayName) {
selectedSystem = system
}
}
} label: {
Text(selectedSystem.displayName)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
}
let filteredData = progressData.filter { $0.difficultySystem == selectedSystem }
if !filteredData.isEmpty {
VStack {
// Simple text-based chart placeholder
VStack(alignment: .leading, spacing: 8) {
ForEach(filteredData.indices.prefix(5), id: \.self) { index in
let point = filteredData[index]
HStack {
Text("Session \(index + 1)")
.font(.caption)
.frame(width: 80, alignment: .leading)
Rectangle()
.fill(.blue)
.frame(width: CGFloat(point.maxGradeNumeric * 5), height: 20)
Text(point.maxGrade)
.font(.caption)
.foregroundColor(.blue)
}
}
if filteredData.count > 5 {
Text("... and \(filteredData.count - 5) more sessions")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.frame(height: 200)
Text(
"X: session number, Y: max \(selectedSystem.displayName.lowercased()) grade achieved"
)
.font(.caption)
.foregroundColor(.secondary)
} else {
VStack(spacing: 8) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.title)
.foregroundColor(.secondary)
Text("No progress data available for \(selectedSystem.displayName) system")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(height: 200)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
.onAppear {
if let firstSystem = usedSystems.first {
selectedSystem = firstSystem
}
}
}
private func calculateProgressOverTime() -> [ProgressDataPoint] {
let sessions = dataManager.completedSessions().sorted { $0.date < $1.date }
let problems = dataManager.problems
let attempts = dataManager.attempts
return sessions.compactMap { session in
let sessionAttempts = attempts.filter { $0.sessionId == session.id }
let attemptedProblemIds = sessionAttempts.map { $0.problemId }
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
guard
let highestGradeProblem = attemptedProblems.max(by: {
$0.difficulty.numericValue < $1.difficulty.numericValue
})
else {
return nil
}
return ProgressDataPoint(
date: session.date,
maxGrade: highestGradeProblem.difficulty.grade,
maxGradeNumeric: highestGradeProblem.difficulty.numericValue,
climbType: highestGradeProblem.climbType,
difficultySystem: highestGradeProblem.difficulty.system
)
}
}
}
struct FavoriteGymSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
private var favoriteGymInfo: (gym: Gym, sessionCount: Int)? {
let gymSessionCounts = Dictionary(grouping: dataManager.sessions, by: { $0.gymId })
.mapValues { $0.count }
guard let mostUsedGymId = gymSessionCounts.max(by: { $0.value < $1.value })?.key,
let gym = dataManager.gym(withId: mostUsedGymId)
else {
return nil
}
return (gym, gymSessionCounts[mostUsedGymId] ?? 0)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Favorite Gym")
.font(.title2)
.fontWeight(.bold)
if let info = favoriteGymInfo {
VStack(alignment: .leading, spacing: 8) {
Text(info.gym.name)
.font(.title3)
.fontWeight(.semibold)
Text("\(info.sessionCount) sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
} else {
Text("No sessions yet")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct RecentActivitySection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
private var recentSessionsCount: Int {
dataManager.sessions.count
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Recent Activity")
.font(.title2)
.fontWeight(.bold)
if recentSessionsCount > 0 {
Text("You've had \(recentSessionsCount) sessions")
.font(.subheadline)
} else {
Text("No recent activity")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct ProgressDataPoint {
let date: Date
let maxGrade: String
let maxGradeNumeric: Int
let climbType: ClimbType
let difficultySystem: DifficultySystem
}
// MARK: - Helper Functions
func gradeToNumeric(_ system: DifficultySystem, _ grade: String) -> Int {
switch system {
case .vScale:
if grade == "VB" { return 0 }
return Int(grade.replacingOccurrences(of: "V", with: "")) ?? 0
case .font:
let fontMapping: [String: Int] = [
"3": 3, "4A": 4, "4B": 5, "4C": 6, "5A": 7, "5B": 8, "5C": 9,
"6A": 10, "6A+": 11, "6B": 12, "6B+": 13, "6C": 14, "6C+": 15,
"7A": 16, "7A+": 17, "7B": 18, "7B+": 19, "7C": 20, "7C+": 21,
"8A": 22, "8A+": 23, "8B": 24, "8B+": 25, "8C": 26, "8C+": 27,
]
return fontMapping[grade] ?? 0
case .yds:
let ydsMapping: [String: Int] = [
"5.0": 50, "5.1": 51, "5.2": 52, "5.3": 53, "5.4": 54, "5.5": 55,
"5.6": 56, "5.7": 57, "5.8": 58, "5.9": 59, "5.10a": 60, "5.10b": 61,
"5.10c": 62, "5.10d": 63, "5.11a": 64, "5.11b": 65, "5.11c": 66,
"5.11d": 67, "5.12a": 68, "5.12b": 69, "5.12c": 70, "5.12d": 71,
"5.13a": 72, "5.13b": 73, "5.13c": 74, "5.13d": 75, "5.14a": 76,
"5.14b": 77, "5.14c": 78, "5.14d": 79, "5.15a": 80, "5.15b": 81,
"5.15c": 82, "5.15d": 83,
]
return ydsMapping[grade] ?? 0
case .custom:
return Int(grade) ?? 0
}
}
func numericToGrade(_ system: DifficultySystem, _ numeric: Int) -> String {
switch system {
case .vScale:
return numeric == 0 ? "VB" : "V\(numeric)"
case .font:
let fontMapping: [Int: String] = [
3: "3", 4: "4A", 5: "4B", 6: "4C", 7: "5A", 8: "5B", 9: "5C",
10: "6A", 11: "6A+", 12: "6B", 13: "6B+", 14: "6C", 15: "6C+",
16: "7A", 17: "7A+", 18: "7B", 19: "7B+", 20: "7C", 21: "7C+",
22: "8A", 23: "8A+", 24: "8B", 25: "8B+", 26: "8C", 27: "8C+",
]
return fontMapping[numeric] ?? "\(numeric)"
case .yds:
let ydsMapping: [Int: String] = [
50: "5.0", 51: "5.1", 52: "5.2", 53: "5.3", 54: "5.4", 55: "5.5",
56: "5.6", 57: "5.7", 58: "5.8", 59: "5.9", 60: "5.10a", 61: "5.10b",
62: "5.10c", 63: "5.10d", 64: "5.11a", 65: "5.11b", 66: "5.11c",
67: "5.11d", 68: "5.12a", 69: "5.12b", 70: "5.12c", 71: "5.12d",
72: "5.13a", 73: "5.13b", 74: "5.13c", 75: "5.13d", 76: "5.14a",
77: "5.14b", 78: "5.14c", 79: "5.14d", 80: "5.15a", 81: "5.15b",
82: "5.15c", 83: "5.15d",
]
return ydsMapping[numeric] ?? "\(numeric)"
case .custom:
return "\(numeric)"
}
}
#Preview {
AnalyticsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,430 @@
//
// GymDetailView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct GymDetailView: View {
let gymId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
private var gym: Gym? {
dataManager.gym(withId: gymId)
}
private var problems: [Problem] {
dataManager.problems(forGym: gymId)
}
private var sessions: [ClimbSession] {
dataManager.sessions(forGym: gymId)
}
private var gymAttempts: [Attempt] {
let problemIds = Set(problems.map { $0.id })
return dataManager.attempts.filter { problemIds.contains($0.problemId) }
}
private var gymStats: GymStats {
calculateGymStats()
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if let gym = gym {
GymHeaderCard(gym: gym)
GymStatsCard(stats: gymStats)
if !problems.isEmpty {
RecentProblemsSection(problems: problems.prefix(5))
}
if !sessions.isEmpty {
RecentSessionsSection(sessions: sessions.prefix(3))
}
if problems.isEmpty && sessions.isEmpty {
EmptyGymStateView()
}
} else {
Text("Gym not found")
.foregroundColor(.secondary)
}
}
.padding()
}
.navigationTitle(gym?.name ?? "Gym Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if gym != nil {
Menu {
Button("Edit Gym") {
// Navigate to edit view
}
Button(role: .destructive) {
showingDeleteAlert = true
} label: {
Label("Delete Gym", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.alert("Delete Gym", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
if let gym = gym {
dataManager.deleteGym(gym)
dismiss()
}
}
} message: {
Text(
"Are you sure you want to delete this gym? This will also delete all problems and sessions associated with this gym."
)
}
}
private func calculateGymStats() -> GymStats {
let uniqueProblemsClimbed = Set(gymAttempts.map { $0.problemId }).count
let totalSessions = sessions.count
let activeSessions = sessions.count { $0.status == .active }
return GymStats(
totalProblems: problems.count,
totalSessions: totalSessions,
totalAttempts: gymAttempts.count,
uniqueProblemsClimbed: uniqueProblemsClimbed,
activeSessions: activeSessions
)
}
}
struct GymHeaderCard: View {
let gym: Gym
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(gym.name)
.font(.title)
.fontWeight(.bold)
if let location = gym.location, !location.isEmpty {
Text(location)
.font(.subheadline)
.foregroundColor(.secondary)
}
if let notes = gym.notes, !notes.isEmpty {
Text(notes)
.font(.body)
.padding(.top, 4)
}
}
// Supported Climb Types
if !gym.supportedClimbTypes.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Climb Types")
.font(.headline)
.fontWeight(.semibold)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(gym.supportedClimbTypes, id: \.self) { climbType in
Text(climbType.displayName)
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
.padding(.horizontal, 1)
}
}
}
// Difficulty Systems
if !gym.difficultySystems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Difficulty Systems")
.font(.headline)
.fontWeight(.semibold)
Text(gym.difficultySystems.map { $0.displayName }.joined(separator: ", "))
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct GymStatsCard: View {
let stats: GymStats
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Statistics")
.font(.title2)
.fontWeight(.bold)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
StatItem(label: "Problems", value: "\(stats.totalProblems)")
StatItem(label: "Sessions", value: "\(stats.totalSessions)")
StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
StatItem(label: "Problems Climbed", value: "\(stats.uniqueProblemsClimbed)")
}
if stats.activeSessions > 0 {
HStack {
StatItem(label: "Active Sessions", value: "\(stats.activeSessions)")
Spacer()
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct RecentProblemsSection: View {
let problems: any Sequence<Problem>
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(
"Problems (\(dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count))"
)
.font(.title2)
.fontWeight(.bold)
LazyVStack(spacing: 12) {
ForEach(Array(problems), id: \.id) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRowCard(problem: problem)
}
.buttonStyle(.plain)
}
}
if dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count > 5 {
Text(
"... and \(dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count - 5) more problems"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct RecentSessionsSection: View {
let sessions: any Sequence<ClimbSession>
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(
"Recent Sessions (\(dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count))"
)
.font(.title2)
.fontWeight(.bold)
LazyVStack(spacing: 12) {
ForEach(Array(sessions), id: \.id) { session in
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
SessionRowCard(session: session)
}
.buttonStyle(.plain)
}
}
if dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count > 3 {
Text(
"... and \(dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count - 3) more sessions"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct ProblemRowCard: View {
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
private var problemAttempts: [Attempt] {
dataManager.attempts(forProblem: problem.id)
}
private var isCompleted: Bool {
problemAttempts.contains { $0.result.isSuccessful }
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text(
"\(problem.difficulty.grade)\(problem.climbType.displayName)\(problemAttempts.count) attempts"
)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.stroke(.quaternary, lineWidth: 1)
)
}
}
struct SessionRowCard: View {
let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager
private var sessionAttempts: [Attempt] {
dataManager.attempts(forSession: session.id)
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(session.status == .active ? "Active Session" : "Session")
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
if session.status == .active {
Text("ACTIVE")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(.green.opacity(0.2))
)
.foregroundColor(.green)
}
}
Text("\(formatDate(session.date))\(sessionAttempts.count) attempts")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if let duration = session.duration {
Text("\(duration)min")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.stroke(.quaternary, lineWidth: 1)
)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
struct EmptyGymStateView: View {
var body: some View {
VStack(spacing: 20) {
Image(systemName: "figure.climbing")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text("No activity yet")
.font(.title2)
.fontWeight(.bold)
Text("Start a session or add problems to see them here")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.padding(40)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct GymStats {
let totalProblems: Int
let totalSessions: Int
let totalAttempts: Int
let uniqueProblemsClimbed: Int
let activeSessions: Int
}
#Preview {
NavigationView {
GymDetailView(gymId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}

View File

@@ -0,0 +1,476 @@
//
// 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)
}
}

View File

@@ -0,0 +1,443 @@
//
// SessionDetailView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct SessionDetailView: View {
let sessionId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
@State private var showingAddAttempt = false
@State private var editingAttempt: Attempt?
private var session: ClimbSession? {
dataManager.session(withId: sessionId)
}
private var gym: Gym? {
guard let session = session else { return nil }
return dataManager.gym(withId: session.gymId)
}
private var attempts: [Attempt] {
dataManager.attempts(forSession: sessionId)
}
private var attemptsWithProblems: [(Attempt, Problem)] {
attempts.compactMap { attempt in
guard let problem = dataManager.problem(withId: attempt.problemId) else { return nil }
return (attempt, problem)
}.sorted { $0.0.timestamp < $1.0.timestamp }
}
private var sessionStats: SessionStats {
calculateSessionStats()
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if let session = session, let gym = gym {
SessionHeaderCard(session: session, gym: gym, stats: sessionStats)
SessionStatsCard(stats: sessionStats)
AttemptsSection(attemptsWithProblems: attemptsWithProblems)
} else {
Text("Session not found")
.foregroundColor(.secondary)
}
}
.padding()
}
.navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if let session = session {
if session.status == .active {
Button("End Session") {
dataManager.endSession(session.id)
dismiss()
}
.foregroundColor(.orange)
} else {
Menu {
Button(role: .destructive) {
showingDeleteAlert = true
} label: {
Label("Delete Session", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
}
.overlay(alignment: .bottomTrailing) {
if session?.status == .active {
Button(action: { showingAddAttempt = true }) {
Image(systemName: "plus")
.font(.title2)
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Circle().fill(.blue))
.shadow(radius: 4)
}
.padding()
}
}
.alert("Delete Session", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
if let session = session {
dataManager.deleteSession(session)
dismiss()
}
}
} message: {
Text(
"Are you sure you want to delete this session? This will also delete all attempts associated with this session."
)
}
.sheet(isPresented: $showingAddAttempt) {
if let session = session, let gym = gym {
AddAttemptView(session: session, gym: gym)
}
}
.sheet(item: $editingAttempt) { attempt in
EditAttemptView(attempt: attempt)
}
}
private func calculateSessionStats() -> SessionStats {
let successfulAttempts = attempts.filter { $0.result.isSuccessful }
let uniqueProblems = Set(attempts.map { $0.problemId })
let completedProblems = Set(successfulAttempts.map { $0.problemId })
let attemptedProblems = uniqueProblems.compactMap { dataManager.problem(withId: $0) }
let boulderProblems = attemptedProblems.filter { $0.climbType == .boulder }
let ropeProblems = attemptedProblems.filter { $0.climbType == .rope }
let boulderRange = gradeRange(for: boulderProblems)
let ropeRange = gradeRange(for: ropeProblems)
return SessionStats(
totalAttempts: attempts.count,
successfulAttempts: successfulAttempts.count,
uniqueProblemsAttempted: uniqueProblems.count,
uniqueProblemsCompleted: completedProblems.count,
boulderRange: boulderRange,
ropeRange: ropeRange
)
}
private func gradeRange(for problems: [Problem]) -> String? {
guard !problems.isEmpty else { return nil }
let grades = problems.map { $0.difficulty }.sorted()
if grades.count == 1 {
return grades.first?.grade
} else {
return "\(grades.first?.grade ?? "") - \(grades.last?.grade ?? "")"
}
}
}
struct SessionHeaderCard: View {
let session: ClimbSession
let gym: Gym
let stats: SessionStats
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(gym.name)
.font(.title)
.fontWeight(.bold)
Text(formatDate(session.date))
.font(.title2)
.foregroundColor(.blue)
if let duration = session.duration {
Text("Duration: \(duration) minutes")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let notes = session.notes, !notes.isEmpty {
Text(notes)
.font(.body)
.padding(.top, 4)
}
}
// Status indicator
HStack {
Image(systemName: session.status == .active ? "play.fill" : "checkmark.circle.fill")
.foregroundColor(session.status == .active ? .green : .blue)
Text(session.status == .active ? "In Progress" : "Completed")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(session.status == .active ? .green : .blue)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill((session.status == .active ? Color.green : Color.blue).opacity(0.1))
)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .full
return formatter.string(from: date)
}
}
struct SessionStatsCard: View {
let stats: SessionStats
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Session Stats")
.font(.title2)
.fontWeight(.bold)
if stats.totalAttempts == 0 {
Text("No attempts recorded yet")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
StatItem(label: "Problems", value: "\(stats.uniqueProblemsAttempted)")
StatItem(label: "Successful", value: "\(stats.successfulAttempts)")
StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)")
}
// Grade ranges
VStack(alignment: .leading, spacing: 8) {
if let boulderRange = stats.boulderRange, let ropeRange = stats.ropeRange {
HStack {
StatItem(label: "Boulder Range", value: boulderRange)
StatItem(label: "Rope Range", value: ropeRange)
}
} else if let singleRange = stats.boulderRange ?? stats.ropeRange {
StatItem(label: "Grade Range", value: singleRange)
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct StatItem: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
}
}
struct AttemptsSection: View {
let attemptsWithProblems: [(Attempt, Problem)]
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var editingAttempt: Attempt?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Attempts (\(attemptsWithProblems.count))")
.font(.title2)
.fontWeight(.bold)
if attemptsWithProblems.isEmpty {
VStack(spacing: 12) {
Image(systemName: "hand.raised.slash")
.font(.title)
.foregroundColor(.secondary)
Text("No attempts yet")
.font(.headline)
.foregroundColor(.secondary)
Text("Start attempting problems to see your progress!")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
} else {
LazyVStack(spacing: 12) {
ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem)
.onTapGesture {
editingAttempt = attempt
}
}
}
}
}
.sheet(item: $editingAttempt) { attempt in
EditAttemptView(attempt: attempt)
}
}
}
struct AttemptCard: View {
let attempt: Attempt
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingDeleteAlert = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unknown Problem")
.font(.headline)
.fontWeight(.semibold)
Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
.font(.subheadline)
.foregroundColor(.blue)
if let location = problem.location {
Text(location)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
AttemptResultBadge(result: attempt.result)
HStack(spacing: 12) {
Button(action: { showingDeleteAlert = true }) {
Image(systemName: "trash")
.font(.caption)
.foregroundColor(.red)
}
.buttonStyle(.plain)
}
}
}
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)
.stroke(.quaternary, lineWidth: 1)
)
.alert("Delete Attempt", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
dataManager.deleteAttempt(attempt)
}
} message: {
Text("Are you sure you want to delete this attempt?")
}
}
}
struct AttemptResultBadge: View {
let result: AttemptResult
private var badgeColor: Color {
switch result {
case .success, .flash:
return .green
case .fall:
return .orange
case .noProgress:
return .red
}
}
var body: some View {
Text(result.displayName)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(badgeColor.opacity(0.1))
)
.foregroundColor(badgeColor)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(badgeColor.opacity(0.3), lineWidth: 1)
)
}
}
struct SessionStats {
let totalAttempts: Int
let successfulAttempts: Int
let uniqueProblemsAttempted: Int
let uniqueProblemsCompleted: Int
let boulderRange: String?
let ropeRange: String?
}
#Preview {
NavigationView {
SessionDetailView(sessionId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}

View File

@@ -0,0 +1,171 @@
//
// GymsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct GymsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddGym = false
var body: some View {
NavigationView {
VStack {
if dataManager.gyms.isEmpty {
EmptyGymsView()
} else {
GymsList()
}
}
.navigationTitle("Gyms")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add") {
showingAddGym = true
}
}
}
.sheet(isPresented: $showingAddGym) {
AddEditGymView()
}
}
}
}
struct GymsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
List(dataManager.gyms, id: \.id) { gym in
NavigationLink(destination: GymDetailView(gymId: gym.id)) {
GymRow(gym: gym)
}
}
.listStyle(.plain)
}
}
struct GymRow: View {
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
private var problemCount: Int {
dataManager.problems(forGym: gym.id).count
}
private var sessionCount: Int {
dataManager.sessions(forGym: gym.id).count
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text(gym.name)
.font(.headline)
.fontWeight(.bold)
if let location = gym.location, !location.isEmpty {
Text(location)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
// Climb Types
if !gym.supportedClimbTypes.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(gym.supportedClimbTypes, id: \.self) { climbType in
Text(climbType.displayName)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
}
}
// Difficulty Systems
if !gym.difficultySystems.isEmpty {
Text(
"Systems: \(gym.difficultySystems.map { $0.displayName }.joined(separator: ", "))"
)
.font(.caption)
.foregroundColor(.secondary)
}
// Stats
HStack {
Label("\(problemCount)", systemImage: "star.fill")
.font(.caption)
.foregroundColor(.orange)
Label("\(sessionCount)", systemImage: "play.fill")
.font(.caption)
.foregroundColor(.green)
Spacer()
}
// Notes preview
if let notes = gym.notes, !notes.isEmpty {
Text(notes)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
}
struct EmptyGymsView: View {
@State private var showingAddGym = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "location.fill")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text("No Gyms Added")
.font(.title2)
.fontWeight(.bold)
Text("Add your favorite climbing gyms to start tracking your progress!")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
Button("Add Gym") {
showingAddGym = true
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Spacer()
}
.sheet(isPresented: $showingAddGym) {
AddEditGymView()
}
}
}
#Preview {
GymsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,362 @@
//
// ProblemsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
struct ProblemsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddProblem = false
@State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym?
@State private var searchText = ""
private var filteredProblems: [Problem] {
var filtered = dataManager.problems
// Apply search filter
if !searchText.isEmpty {
filtered = filtered.filter { problem in
(problem.name?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.location?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.setter?.localizedCaseInsensitiveContains(searchText) ?? false)
|| problem.tags.contains { $0.localizedCaseInsensitiveContains(searchText) }
}
}
// Apply climb type filter
if let climbType = selectedClimbType {
filtered = filtered.filter { $0.climbType == climbType }
}
// Apply gym filter
if let gym = selectedGym {
filtered = filtered.filter { $0.gymId == gym.id }
}
return filtered.sorted { $0.updatedAt > $1.updatedAt }
}
var body: some View {
NavigationView {
VStack(spacing: 0) {
if !dataManager.problems.isEmpty {
FilterSection()
.padding()
.background(.regularMaterial)
}
if filteredProblems.isEmpty {
EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty
)
} else {
ProblemsList(problems: filteredProblems)
}
}
.navigationTitle("Problems")
.searchable(text: $searchText, prompt: "Search problems...")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if !dataManager.gyms.isEmpty {
Button("Add") {
showingAddProblem = true
}
}
}
}
.sheet(isPresented: $showingAddProblem) {
AddEditProblemView()
}
}
}
}
struct FilterSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym?
var body: some View {
VStack(spacing: 12) {
// Climb Type Filter
VStack(alignment: .leading, spacing: 8) {
Text("Climb Type")
.font(.subheadline)
.fontWeight(.medium)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "All Types",
isSelected: selectedClimbType == nil
) {
selectedClimbType = nil
}
ForEach(ClimbType.allCases, id: \.self) { climbType in
FilterChip(
title: climbType.displayName,
isSelected: selectedClimbType == climbType
) {
selectedClimbType = climbType
}
}
}
.padding(.horizontal, 1)
}
}
// Gym Filter
VStack(alignment: .leading, spacing: 8) {
Text("Gym")
.font(.subheadline)
.fontWeight(.medium)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "All Gyms",
isSelected: selectedGym == nil
) {
selectedGym = nil
}
ForEach(dataManager.gyms, id: \.id) { gym in
FilterChip(
title: gym.name,
isSelected: selectedGym?.id == gym.id
) {
selectedGym = gym
}
}
}
.padding(.horizontal, 1)
}
}
// Results count
if selectedClimbType != nil || selectedGym != nil {
HStack {
Text(
"Showing \(filteredProblems.count) of \(dataManager.problems.count) problems"
)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
}
}
}
private var filteredProblems: [Problem] {
var filtered = dataManager.problems
if let climbType = selectedClimbType {
filtered = filtered.filter { $0.climbType == climbType }
}
if let gym = selectedGym {
filtered = filtered.filter { $0.gymId == gym.id }
}
return filtered
}
}
struct FilterChip: View {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(isSelected ? .blue : .clear)
.stroke(.blue, lineWidth: 1)
)
.foregroundColor(isSelected ? .white : .blue)
}
.buttonStyle(.plain)
}
}
struct ProblemsList: View {
let problems: [Problem]
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
List(problems) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRow(problem: problem)
}
}
.listStyle(.plain)
}
}
struct ProblemRow: View {
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
private var gym: Gym? {
dataManager.gym(withId: problem.gymId)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
.fontWeight(.semibold)
Text(gym?.name ?? "Unknown Gym")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(problem.difficulty.grade)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
Text(problem.climbType.displayName)
.font(.caption)
.foregroundColor(.secondary)
}
}
if let location = problem.location {
Text("Location: \(location)")
.font(.caption)
.foregroundColor(.secondary)
}
if !problem.tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(problem.tags.prefix(3), id: \.self) { tag in
Text(tag)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(.blue.opacity(0.1))
)
.foregroundColor(.blue)
}
}
}
}
if !problem.imagePaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in
AsyncImage(url: URL(fileURLWithPath: imagePath)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
}
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
}
}
}
}
if !problem.isActive {
Text("Inactive")
.font(.caption)
.foregroundColor(.red)
.fontWeight(.medium)
}
}
.padding(.vertical, 4)
}
}
struct EmptyProblemsView: View {
let isEmpty: Bool
let isFiltered: Bool
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddProblem = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text(title)
.font(.title2)
.fontWeight(.bold)
Text(subtitle)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
if isEmpty && !dataManager.gyms.isEmpty {
Button("Add Problem") {
showingAddProblem = true
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Spacer()
}
.sheet(isPresented: $showingAddProblem) {
AddEditProblemView()
}
}
private var title: String {
if isEmpty {
return dataManager.gyms.isEmpty ? "No Gyms Available" : "No Problems Yet"
} else {
return "No Problems Match Filters"
}
}
private var subtitle: String {
if isEmpty {
return dataManager.gyms.isEmpty
? "Add a gym first to start tracking problems and routes!"
: "Start tracking your favorite problems and routes!"
} else {
return "Try adjusting your filters to see more problems."
}
}
}
#Preview {
ProblemsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,243 @@
//
// SessionsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import Combine
import SwiftUI
struct SessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
var body: some View {
NavigationView {
VStack(spacing: 0) {
// Active session banner
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
VStack(spacing: 8) {
ActiveSessionBanner(session: activeSession, gym: gym)
.padding(.horizontal)
}
.padding(.top, 8)
}
// Sessions list
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
EmptySessionsView()
} else {
SessionsList()
}
}
.navigationTitle("Sessions")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.gyms.isEmpty {
EmptyView()
} else if dataManager.activeSession == nil {
Button("Start Session") {
if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id)
} else {
showingAddSession = true
}
}
}
}
}
.sheet(isPresented: $showingAddSession) {
AddEditSessionView()
}
}
}
}
struct ActiveSessionBanner: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var currentTime = Date()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "play.fill")
.foregroundColor(.green)
.font(.caption)
Text("Active Session")
.font(.headline)
.fontWeight(.bold)
}
Text(gym.name)
.font(.subheadline)
.foregroundColor(.secondary)
if let startTime = session.startTime {
Text(formatDuration(from: startTime, to: currentTime))
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Button("End") {
dataManager.endSession(session.id)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
.onReceive(timer) { _ in
currentTime = Date()
}
}
private func formatDuration(from start: Date, to end: Date) -> String {
let interval = end.timeIntervalSince(start)
let hours = Int(interval) / 3600
let minutes = Int(interval) % 3600 / 60
let seconds = Int(interval) % 60
if hours > 0 {
return String(format: "%dh %dm %ds", hours, minutes, seconds)
} else if minutes > 0 {
return String(format: "%dm %ds", minutes, seconds)
} else {
return String(format: "%ds", seconds)
}
}
}
struct SessionsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
private var completedSessions: [ClimbSession] {
dataManager.sessions
.filter { $0.status == .completed }
.sorted { $0.date > $1.date }
}
var body: some View {
List(completedSessions) { session in
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
SessionRow(session: session)
}
}
.listStyle(.plain)
}
}
struct SessionRow: View {
let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager
private var gym: Gym? {
dataManager.gym(withId: session.gymId)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(gym?.name ?? "Unknown Gym")
.font(.headline)
.fontWeight(.semibold)
Spacer()
Text(formatDate(session.date))
.font(.caption)
.foregroundColor(.secondary)
}
if let duration = session.duration {
Text("Duration: \(duration) minutes")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let notes = session.notes, !notes.isEmpty {
Text(notes)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
struct EmptySessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "figure.climbing")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text(dataManager.gyms.isEmpty ? "No Gyms Available" : "No Sessions Yet")
.font(.title2)
.fontWeight(.bold)
Text(
dataManager.gyms.isEmpty
? "Add a gym first to start tracking your climbing sessions!"
: "Start your first climbing session!"
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
if !dataManager.gyms.isEmpty {
Button("Start Session") {
if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id)
} else {
showingAddSession = true
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Spacer()
}
.sheet(isPresented: $showingAddSession) {
AddEditSessionView()
}
}
}
#Preview {
SessionsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,441 @@
//
// SettingsView.swift
// OpenClimb
//
// Created by OpenClimb on 2025-01-17.
//
import SwiftUI
import UniformTypeIdentifiers
struct SettingsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingResetAlert = false
@State private var showingExportSheet = false
@State private var showingImportSheet = false
@State private var exportData: Data?
var body: some View {
NavigationView {
List {
DataManagementSection()
AppInfoSection()
}
.navigationTitle("Settings")
}
}
}
struct DataManagementSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingResetAlert = false
@State private var showingExportSheet = false
@State private var showingImportSheet = false
@State private var exportData: Data?
@State private var isExporting = false
var body: some View {
Section("Data Management") {
// Export Data
Button(action: {
exportDataAsync()
}) {
HStack {
if isExporting {
ProgressView()
.scaleEffect(0.8)
Text("Exporting...")
.foregroundColor(.secondary)
} else {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.blue)
Text("Export Data")
}
Spacer()
}
}
.disabled(isExporting)
.foregroundColor(.primary)
// Import Data
Button(action: {
showingImportSheet = true
}) {
HStack {
Image(systemName: "square.and.arrow.down")
.foregroundColor(.green)
Text("Import Data")
Spacer()
}
}
.foregroundColor(.primary)
// Reset All Data
Button(action: {
showingResetAlert = true
}) {
HStack {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Reset All Data")
Spacer()
}
}
.foregroundColor(.red)
}
.alert("Reset All Data", isPresented: $showingResetAlert) {
Button("Cancel", role: .cancel) {}
Button("Reset", role: .destructive) {
dataManager.resetAllData()
}
} message: {
Text(
"Are you sure you want to reset all data? This will permanently delete:\n\n• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data\n\nThis action cannot be undone. Consider exporting your data first."
)
}
.sheet(isPresented: $showingExportSheet) {
if let data = exportData {
ExportDataView(data: data)
} else {
Text("No export data available")
}
}
.sheet(isPresented: $showingImportSheet) {
ImportDataView()
}
}
private func exportDataAsync() {
isExporting = true
Task {
let data = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let result = dataManager.exportData()
continuation.resume(returning: result)
}
}
await MainActor.run {
isExporting = false
if let data = data {
exportData = data
showingExportSheet = true
} else {
// Error message should already be set by dataManager
exportData = nil
}
}
}
}
}
struct AppInfoSection: View {
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
}
var body: some View {
Section("App Information") {
HStack {
Image(systemName: "mountain.2.fill")
.foregroundColor(.blue)
VStack(alignment: .leading) {
Text("OpenClimb")
.font(.headline)
Text("Track your climbing progress")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
Text("Version")
Spacer()
Text("\(appVersion) (\(buildNumber))")
.foregroundColor(.secondary)
}
HStack {
Image(systemName: "person.fill")
.foregroundColor(.blue)
Text("Developer")
Spacer()
Text("OpenClimb Team")
.foregroundColor(.secondary)
}
}
}
}
struct ExportDataView: View {
let data: Data
@Environment(\.dismiss) private var dismiss
@State private var tempFileURL: URL?
var body: some View {
NavigationView {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 60))
.foregroundColor(.blue)
Text("Export Data")
.font(.title)
.fontWeight(.bold)
Text(
"Your climbing data has been prepared for export. Use the share button below to save or send your data."
)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let fileURL = tempFileURL {
ShareLink(
item: fileURL,
preview: SharePreview(
"OpenClimb Data Export",
image: Image(systemName: "mountain.2.fill"))
) {
Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
)
}
.padding(.horizontal)
.buttonStyle(.plain)
} else {
Button(action: {}) {
Label("Preparing Export...", systemImage: "hourglass")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.gray)
)
}
.disabled(true)
.padding(.horizontal)
}
Spacer()
}
.padding()
.navigationTitle("Export")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.onAppear {
if tempFileURL == nil {
createTempFile()
}
}
.onDisappear {
// Delay cleanup to ensure sharing is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
cleanupTempFile()
}
}
}
}
private func createTempFile() {
DispatchQueue.global(qos: .userInitiated).async {
do {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoString = formatter.string(from: Date())
let timestamp = isoString.replacingOccurrences(of: ":", with: "-")
.replacingOccurrences(of: ".", with: "-")
let filename = "openclimb_export_\(timestamp).zip"
guard
let documentsURL = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first
else {
print("Could not access Documents directory")
return
}
let fileURL = documentsURL.appendingPathComponent(filename)
// Write the ZIP data to the file
try data.write(to: fileURL)
DispatchQueue.main.async {
self.tempFileURL = fileURL
}
} catch {
print("Failed to create export file: \(error)")
}
}
}
private func cleanupTempFile() {
if let fileURL = tempFileURL {
// Clean up after a delay to ensure sharing is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
try? FileManager.default.removeItem(at: fileURL)
print("Cleaned up export file: \(fileURL.lastPathComponent)")
}
}
}
}
struct ImportDataView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var isImporting = false
@State private var importError: String?
@State private var showingDocumentPicker = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Import Data")
.font(.title)
.fontWeight(.bold)
VStack(spacing: 12) {
Text("Import climbing data from a previously exported ZIP file.")
.multilineTextAlignment(.center)
Text(
"Fully compatible with Android exports - identical ZIP format with images."
)
.font(.subheadline)
.foregroundColor(.blue)
.multilineTextAlignment(.center)
Text("⚠️ Warning: This will replace all current data!")
.font(.subheadline)
.foregroundColor(.red)
.multilineTextAlignment(.center)
}
.padding(.horizontal)
Button(action: {
showingDocumentPicker = true
}) {
if isImporting {
HStack {
ProgressView()
.scaleEffect(0.8)
Text("Importing...")
}
} else {
Label("Select ZIP File to Import", systemImage: "folder.badge.plus")
}
}
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isImporting ? .gray : .green)
)
.padding(.horizontal)
.disabled(isImporting)
if let error = importError {
Text(error)
.foregroundColor(.red)
.padding()
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.red.opacity(0.1))
)
}
Spacer()
}
.padding()
.navigationTitle("Import Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") {
dismiss()
}
}
}
.fileImporter(
isPresented: $showingDocumentPicker,
allowedContentTypes: [.zip, .archive],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
if let url = urls.first {
importData(from: url)
}
case .failure(let error):
importError = "Failed to select file: \(error.localizedDescription)"
}
}
}
}
private func importData(from url: URL) {
isImporting = true
importError = nil
Task {
do {
// Access the security-scoped resource
guard url.startAccessingSecurityScopedResource() else {
await MainActor.run {
isImporting = false
importError = "Failed to access selected file"
}
return
}
defer { url.stopAccessingSecurityScopedResource() }
let data = try Data(contentsOf: url)
try dataManager.importData(from: data)
await MainActor.run {
isImporting = false
dismiss()
}
} catch {
await MainActor.run {
isImporting = false
importError = "Import failed: \(error.localizedDescription)"
}
}
}
}
}
#Preview {
SettingsView()
.environmentObject(ClimbingDataManager.preview)
}