Moved to Ascently
All checks were successful
Ascently Docker Deploy / build-and-push (push) Successful in 2m31s

This commit is contained in:
2025-10-13 14:54:54 -06:00
parent 30d2b3938e
commit 09b4055985
137 changed files with 788 additions and 483 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,136 @@
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 {
NavigationStack {
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,547 @@
import SwiftUI
struct AnalyticsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 20) {
OverallStatsSection()
ProgressChartSection()
HStack(spacing: 16) {
FavoriteGymSection()
RecentActivitySection()
}
}
.padding()
}
.navigationTitle("Analytics")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
}
}
}
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
@State private var showAllTime: Bool = true
@State private var cachedGradeCountData: [GradeCount] = []
@State private var lastCalculationDate: Date = Date.distantPast
@State private var lastDataHash: Int = 0
private var gradeCountData: [GradeCount] {
let currentHash =
dataManager.problems.count + dataManager.attempts.count + (showAllTime ? 1 : 0)
let now = Date()
// Recalculate only if data changed or cache is older than 30 seconds
if currentHash != lastDataHash || now.timeIntervalSince(lastCalculationDate) > 30 {
let newData = calculateGradeCounts()
DispatchQueue.main.async {
self.cachedGradeCountData = newData
self.lastCalculationDate = now
self.lastDataHash = currentHash
}
}
return cachedGradeCountData.isEmpty ? calculateGradeCounts() : cachedGradeCountData
}
private var usedSystems: [DifficultySystem] {
let uniqueSystems = Set(gradeCountData.map { $0.difficultySystem })
return uniqueSystems.sorted {
let order: [DifficultySystem] = [.vScale, .font, .yds, .custom]
let firstIndex = order.firstIndex(of: $0) ?? order.count
let secondIndex = order.firstIndex(of: $1) ?? order.count
return firstIndex < secondIndex
}
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Grade Distribution")
.font(.title2)
.fontWeight(.bold)
// Toggles section
HStack {
// Time period toggle
HStack(spacing: 8) {
Button(action: {
showAllTime = true
}) {
Text("All Time")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(showAllTime ? .white : .blue)
}
Button(action: {
showAllTime = false
}) {
Text("7 Days")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(!showAllTime ? .blue : .clear)
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(!showAllTime ? .white : .blue)
}
}
Spacer()
// Scale selector (only show if multiple systems)
if usedSystems.count > 1 {
Menu {
ForEach(usedSystems, id: \.self) { system in
Button(action: {
selectedSystem = system
}) {
HStack {
Text(system.displayName)
if selectedSystem == system {
Spacer()
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
} label: {
HStack(spacing: 4) {
Text(selectedSystem.displayName)
.font(.subheadline)
.fontWeight(.medium)
Image(systemName: "chevron.down")
.font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.blue.opacity(0.1))
.stroke(.blue.opacity(0.3), lineWidth: 1)
)
.foregroundColor(.blue)
}
}
}
let filteredData = gradeCountData.filter { $0.difficultySystem == selectedSystem }
if !filteredData.isEmpty {
BarChartView(data: filteredData)
.frame(height: 200)
Text("Successful climbs by grade")
.font(.caption)
.foregroundColor(.secondary)
} else {
VStack(spacing: 8) {
Image(systemName: "chart.bar")
.font(.title)
.foregroundColor(.secondary)
Text("No 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 calculateGradeCounts() -> [GradeCount] {
let problems = dataManager.problems
let attempts = dataManager.attempts
// Filter attempts by time period
let filteredAttempts: [Attempt]
if showAllTime {
filteredAttempts = attempts.filter { $0.result.isSuccessful }
} else {
let sevenDaysAgo =
Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
filteredAttempts = attempts.filter {
$0.result.isSuccessful && $0.timestamp >= sevenDaysAgo
}
}
// Get attempted problems
let attemptedProblemIds = filteredAttempts.map { $0.problemId }
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
// Group by difficulty system and grade
var gradeCounts: [String: GradeCount] = [:]
for problem in attemptedProblems {
let successfulAttemptsForProblem = filteredAttempts.filter {
$0.problemId == problem.id
}
let count = successfulAttemptsForProblem.count
let key = "\(problem.difficulty.system.rawValue)-\(problem.difficulty.grade)"
if let existing = gradeCounts[key] {
gradeCounts[key] = GradeCount(
grade: existing.grade,
count: existing.count + count,
gradeNumeric: existing.gradeNumeric,
difficultySystem: existing.difficultySystem
)
} else {
gradeCounts[key] = GradeCount(
grade: problem.difficulty.grade,
count: count,
gradeNumeric: problem.difficulty.numericValue,
difficultySystem: problem.difficulty.system
)
}
}
return Array(gradeCounts.values)
}
}
struct GradeCount {
let grade: String
let count: Int
let gradeNumeric: Int
let difficultySystem: DifficultySystem
}
struct BarChartView: View {
let data: [GradeCount]
private var sortedData: [GradeCount] {
data.sorted { $0.gradeNumeric < $1.gradeNumeric }
}
private var maxCount: Int {
data.map { $0.count }.max() ?? 1
}
var body: some View {
GeometryReader { geometry in
let chartWidth = geometry.size.width - 40
let chartHeight = geometry.size.height - 40
let barWidth = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.8
let spacing = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.2
if sortedData.isEmpty {
Rectangle()
.fill(.clear)
.overlay(
Text("No data")
.foregroundColor(.secondary)
)
} else {
VStack(alignment: .leading) {
// Chart area
HStack(alignment: .bottom, spacing: spacing / CGFloat(sortedData.count)) {
ForEach(Array(sortedData.enumerated()), id: \.offset) { index, gradeCount in
VStack(spacing: 4) {
// Bar
RoundedRectangle(cornerRadius: 4)
.fill(.blue)
.frame(
width: barWidth,
height: CGFloat(gradeCount.count) / CGFloat(maxCount)
* chartHeight * 0.8
)
.overlay(
Text("\(gradeCount.count)")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.white)
.opacity(gradeCount.count > 0 ? 1 : 0)
)
// Grade label
Text(gradeCount.grade)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
.frame(height: chartHeight)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
}
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: 16) {
HStack {
Image(systemName: "location.fill")
.font(.title2)
.foregroundColor(.purple)
Text("Favorite Gym")
.font(.title2)
.fontWeight(.bold)
Spacer()
}
if let info = favoriteGymInfo {
VStack(alignment: .leading, spacing: 12) {
Text(info.gym.name)
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(.primary)
HStack {
Image(systemName: "calendar")
.font(.subheadline)
.foregroundColor(.purple)
Text("\(info.sessionCount) sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("No sessions yet")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Start climbing to see your favorite gym!")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
}
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct RecentActivitySection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
private var recentSessionsCount: Int {
dataManager.sessions.count
}
private var totalAttempts: Int {
dataManager.attempts.count
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Image(systemName: "clock.fill")
.font(.title2)
.foregroundColor(.blue)
Text("Recent Activity")
.font(.title2)
.fontWeight(.bold)
Spacer()
}
if recentSessionsCount > 0 {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "play.circle")
.font(.subheadline)
.foregroundColor(.blue)
Text("\(recentSessionsCount) sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
HStack {
Image(systemName: "hand.raised")
.font(.subheadline)
.foregroundColor(.green)
Text("\(totalAttempts) attempts")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("No recent activity")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Start your first session!")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
}
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
#Preview {
AnalyticsView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,425 @@
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 {
// Navigate to edit view
} label: {
Label("Edit Gym", systemImage: "pencil")
}
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,468 @@
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 {
showingEditProblem = true
} label: {
Label("Edit Problem", systemImage: "pencil")
}
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 !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
ProblemDetailImageView(imagePath: imagePaths[index])
.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 {
NavigationStack {
TabView(selection: $currentIndex) {
ForEach(imagePaths.indices, id: \.self) { index in
ProblemDetailImageFullView(imagePath: imagePaths[index])
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.navigationTitle("Photo \(currentIndex + 1) of \(imagePaths.count)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}
struct ProblemDetailImageView: View {
let imagePath: String
var body: some View {
OrientationAwareImage.fill(imagePath: imagePath)
.frame(width: 120, height: 120)
.clipped()
.cornerRadius(12)
}
}
struct ProblemDetailImageFullView: View {
let imagePath: String
var body: some View {
OrientationAwareImage.fit(imagePath: imagePath)
}
}
#Preview {
NavigationView {
ProblemDetailView(problemId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}

View File

@@ -0,0 +1,452 @@
import Combine
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?
@State private var attemptToDelete: 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,
attemptToDelete: $attemptToDelete,
editingAttempt: $editingAttempt)
} 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")
}
}
}
}
}
.alert(
"Delete Attempt",
isPresented: Binding<Bool>(
get: { attemptToDelete != nil },
set: { if !$0 { attemptToDelete = nil } }
)
) {
Button("Cancel", role: .cancel) {
attemptToDelete = nil
}
Button("Delete", role: .destructive) {
if let attempt = attemptToDelete {
dataManager.deleteAttempt(attempt)
attemptToDelete = nil
}
}
} message: {
if let attempt = attemptToDelete,
let problem = dataManager.problem(withId: attempt.problemId)
{
Text(
"Are you sure you want to delete this attempt on \"\(problem.name ?? "Unknown Problem")\"? This action cannot be undone."
)
} else {
Text("Are you sure you want to delete this attempt? This action cannot be undone.")
}
}
.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 })
return SessionStats(
totalAttempts: attempts.count,
successfulAttempts: successfulAttempts.count,
uniqueProblemsAttempted: uniqueProblems.count,
uniqueProblemsCompleted: completedProblems.count
)
}
}
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 session.status == .active {
if let startTime = session.startTime {
Text("Duration: ")
.font(.subheadline)
.foregroundColor(.secondary)
+ Text(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.subheadline)
.foregroundColor(.secondary)
.monospacedDigit()
}
} else 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: "Completed", value: "\(stats.uniqueProblemsCompleted)")
}
}
}
.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)]
@Binding var attemptToDelete: Attempt?
@Binding var editingAttempt: Attempt?
@EnvironmentObject var dataManager: ClimbingDataManager
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 {
List {
ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
// Add haptic feedback for delete action
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
attemptToDelete = attempt
} label: {
Label("Delete", systemImage: "trash")
}
.accessibilityLabel("Delete attempt")
.accessibilityHint("Removes this attempt from the session")
Button {
editingAttempt = attempt
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
.accessibilityLabel("Edit attempt")
.accessibilityHint("Modify the details of this attempt")
}
.onTapGesture {
editingAttempt = attempt
}
}
}
.listStyle(.plain)
.scrollDisabled(true)
.frame(height: CGFloat(attemptsWithProblems.count) * 120)
}
}
}
}
struct AttemptCard: View {
let attempt: Attempt
let problem: Problem
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)
}
}
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(.regularMaterial)
.cornerRadius(12)
.shadow(radius: 2)
}
}
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
}
#Preview {
NavigationView {
SessionDetailView(sessionId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}

View File

@@ -0,0 +1,218 @@
import SwiftUI
struct GymsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddGym = false
var body: some View {
NavigationStack {
VStack {
if dataManager.gyms.isEmpty {
EmptyGymsView()
} else {
GymsList()
}
}
.navigationTitle("Gyms")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
Button("Add") {
showingAddGym = true
}
}
}
.sheet(isPresented: $showingAddGym) {
AddEditGymView()
}
}
}
}
struct GymsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var gymToDelete: Gym?
@State private var gymToEdit: Gym?
var body: some View {
List(dataManager.gyms, id: \.id) { gym in
NavigationLink(destination: GymDetailView(gymId: gym.id)) {
GymRow(gym: gym)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
gymToDelete = gym
} label: {
Label("Delete", systemImage: "trash")
}
Button {
gymToEdit = gym
} label: {
HStack {
Image(systemName: "pencil")
Text("Edit")
}
}
.tint(.blue)
}
}
.alert("Delete Gym", isPresented: .constant(gymToDelete != nil)) {
Button("Cancel", role: .cancel) {
gymToDelete = nil
}
Button("Delete", role: .destructive) {
if let gym = gymToDelete {
dataManager.deleteGym(gym)
gymToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this gym? This will also delete all associated problems and sessions."
)
}
.sheet(item: $gymToEdit) { gym in
AddEditGymView(gymId: gym.id)
}
}
}
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, 8)
}
}
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,278 @@
//
// LiveActivityDebugView.swift
import SwiftUI
struct LiveActivityDebugView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var debugOutput: String = ""
@State private var isTestRunning = false
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 20) {
// Header
VStack(alignment: .leading, spacing: 8) {
Text("Live Activity Debug")
.font(.title)
.fontWeight(.bold)
Text("Test and debug Live Activities for climbing sessions")
.font(.subheadline)
.foregroundColor(.secondary)
}
// Status Section
GroupBox("Current Status") {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "circle.fill")
.foregroundColor(dataManager.activeSession != nil ? .green : .red)
Text(
"Active Session: \(dataManager.activeSession != nil ? "Yes" : "No")"
)
}
HStack {
Image(systemName: "building.2")
Text("Total Gyms: \(dataManager.gyms.count)")
}
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
HStack {
Image(systemName: "location")
Text("Current Gym: \(gym.name)")
}
}
}
}
// Test Buttons
GroupBox("Live Activity Tests") {
VStack(spacing: 16) {
Button(action: checkStatus) {
HStack {
Image(systemName: "checkmark.circle")
Text("Check Live Activity Status")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isTestRunning)
Button(action: testLiveActivity) {
HStack {
Image(systemName: isTestRunning ? "hourglass" : "play.circle")
Text(
isTestRunning
? "Running Test..." : "Run Full Live Activity Test")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(isTestRunning || dataManager.gyms.isEmpty)
Button(action: forceLiveActivityUpdate) {
HStack {
Image(systemName: "arrow.clockwise")
Text("Force Live Activity Update")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(dataManager.activeSession == nil)
if dataManager.gyms.isEmpty {
Text("WARNING: Add at least one gym to test Live Activities")
.font(.caption)
.foregroundColor(.orange)
}
if dataManager.activeSession != nil {
Button(action: endCurrentSession) {
HStack {
Image(systemName: "stop.circle")
Text("End Current Session")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isTestRunning)
}
}
}
// Debug Output
GroupBox("Debug Output") {
ScrollView {
ScrollViewReader { proxy in
VStack(alignment: .leading, spacing: 4) {
if debugOutput.isEmpty {
Text("No debug output yet. Run a test to see details.")
.foregroundColor(.secondary)
.italic()
} else {
Text(debugOutput)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.id("bottom")
.onChange(of: debugOutput) {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
}
.frame(maxHeight: 200)
.background(Color(UIColor.systemGray6))
.cornerRadius(8)
}
// Clear button
HStack {
Spacer()
Button("Clear Output") {
debugOutput = ""
}
.buttonStyle(.bordered)
}
Spacer()
}
.padding()
}
.navigationTitle("Live Activity Debug")
.navigationBarTitleDisplayMode(.inline)
}
private func appendDebugOutput(_ message: String) {
let timestamp = DateFormatter.timeFormatter.string(from: Date())
let newLine = "[\(timestamp)] \(message)"
DispatchQueue.main.async {
if debugOutput.isEmpty {
debugOutput = newLine
} else {
debugOutput += "\n" + newLine
}
}
}
private func checkStatus() {
appendDebugOutput("Checking Live Activity status...")
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
appendDebugOutput("Status: \(status)")
// Check iOS version
if #available(iOS 16.1, *) {
appendDebugOutput("iOS version supports Live Activities")
} else {
appendDebugOutput(
"ERROR: iOS version does not support Live Activities (requires 16.1+)")
}
// Check if we're on simulator
#if targetEnvironment(simulator)
appendDebugOutput(
"WARNING: Running on Simulator - Live Activities have limited functionality")
#else
appendDebugOutput("Running on device - Live Activities should work fully")
#endif
}
private func testLiveActivity() {
guard !dataManager.gyms.isEmpty else {
appendDebugOutput("ERROR: No gyms available for testing")
return
}
isTestRunning = true
appendDebugOutput("🧪 Starting Live Activity test...")
Task {
defer {
DispatchQueue.main.async {
isTestRunning = false
}
}
// Test with first gym
let testGym = dataManager.gyms[0]
appendDebugOutput("Using gym: \(testGym.name)")
// Create test session
let testSession = ClimbSession(
gymId: testGym.id, notes: "Test session for Live Activity")
appendDebugOutput("Created test session")
// Start Live Activity
await LiveActivityManager.shared.startLiveActivity(
for: testSession, gymName: testGym.name)
appendDebugOutput("Live Activity start request sent")
// Wait and update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
appendDebugOutput("Updating Live Activity with test data...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 180,
totalAttempts: 8,
completedProblems: 2
)
// Another update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
appendDebugOutput("Second update...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 360,
totalAttempts: 15,
completedProblems: 4
)
// End after delay
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
appendDebugOutput("Ending Live Activity...")
await LiveActivityManager.shared.endLiveActivity()
appendDebugOutput("Live Activity test completed!")
}
}
private func endCurrentSession() {
guard let activeSession = dataManager.activeSession else {
appendDebugOutput("ERROR: No active session to end")
return
}
appendDebugOutput("Ending current session: \(activeSession.id)")
dataManager.endSession(activeSession.id)
appendDebugOutput("Session ended")
}
private func forceLiveActivityUpdate() {
appendDebugOutput("Forcing Live Activity update...")
dataManager.forceLiveActivityUpdate()
appendDebugOutput("Live Activity update sent")
}
}
extension DateFormatter {
static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
}
#Preview {
LiveActivityDebugView()
.environmentObject(ClimbingDataManager.preview)
}

View File

@@ -0,0 +1,530 @@
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 = ""
@State private var showingSearch = false
@FocusState private var isSearchFocused: Bool
@State private var cachedFilteredProblems: [Problem] = []
private func updateFilteredProblems() {
Task(priority: .userInitiated) {
let result = await computeFilteredProblems()
// Switch back to the main thread to update the UI
await MainActor.run {
cachedFilteredProblems = result
}
}
}
private func computeFilteredProblems() async -> [Problem] {
// Capture dependencies for safe background processing
let problems = dataManager.problems
let searchText = self.searchText
let selectedClimbType = self.selectedClimbType
let selectedGym = self.selectedGym
var filtered = problems
// Apply search filter
if !searchText.isEmpty {
filtered = filtered.filter { problem in
return problem.name?.localizedCaseInsensitiveContains(searchText) ?? false
|| (problem.description?.localizedCaseInsensitiveContains(searchText) ?? false)
|| (problem.location?.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 }
}
// Separate active and inactive problems with stable sorting
let active = filtered.filter { $0.isActive }.sorted {
if $0.updatedAt == $1.updatedAt {
return $0.id.uuidString < $1.id.uuidString // Stable fallback
}
return $0.updatedAt > $1.updatedAt
}
let inactive = filtered.filter { !$0.isActive }.sorted {
if $0.updatedAt == $1.updatedAt {
return $0.id.uuidString < $1.id.uuidString // Stable fallback
}
return $0.updatedAt > $1.updatedAt
}
return active + inactive
}
var body: some View {
NavigationStack {
Group {
VStack(spacing: 0) {
if showingSearch {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 16, weight: .medium))
TextField("Search problems...", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 16))
.focused($isSearchFocused)
.submitLabel(.search)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background {
if #available(iOS 18.0, *) {
RoundedRectangle(cornerRadius: 12)
.fill(.regularMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(.quaternary, lineWidth: 0.5)
}
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemGray4), lineWidth: 0.5)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.animation(.easeInOut(duration: 0.3), value: showingSearch)
}
if !dataManager.problems.isEmpty && !showingSearch {
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: cachedFilteredProblems
)
.padding()
.background(.regularMaterial)
}
if cachedFilteredProblems.isEmpty {
EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty
)
} else {
ProblemsList(problems: cachedFilteredProblems)
}
}
}
.navigationTitle("Problems")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
Button(action: {
withAnimation(.easeInOut(duration: 0.3)) {
showingSearch.toggle()
if showingSearch {
isSearchFocused = true
} else {
searchText = ""
isSearchFocused = false
}
}
}) {
Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass")
.font(.system(size: 16, weight: .medium))
.foregroundColor(showingSearch ? .secondary : .blue)
}
if !dataManager.gyms.isEmpty {
Button("Add") {
showingAddProblem = true
}
}
}
}
.sheet(isPresented: $showingAddProblem) {
AddEditProblemView()
}
}
.onAppear {
updateFilteredProblems()
}
.onChange(of: dataManager.problems) {
updateFilteredProblems()
}
.onChange(of: searchText) {
updateFilteredProblems()
}
.onChange(of: selectedClimbType) {
updateFilteredProblems()
}
.onChange(of: selectedGym) {
updateFilteredProblems()
}
}
}
struct FilterSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@Binding var selectedClimbType: ClimbType?
@Binding var selectedGym: Gym?
let filteredProblems: [Problem]
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()
}
}
}
}
}
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
@State private var problemToDelete: Problem?
@State private var problemToEdit: Problem?
@State private var animationKey = 0
var body: some View {
List(problems, id: \.id) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRow(problem: problem)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
problemToDelete = problem
} label: {
Label("Delete", systemImage: "trash")
}
Button {
// Use a spring animation for more natural movement
withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1))
{
let updatedProblem = problem.updated(isActive: !problem.isActive)
dataManager.updateProblem(updatedProblem)
}
} label: {
Label(
problem.isActive ? "Mark as Reset" : "Mark as Active",
systemImage: problem.isActive ? "xmark.circle" : "checkmark.circle")
}
.tint(.orange)
Button {
problemToEdit = problem
} label: {
HStack {
Image(systemName: "pencil")
Text("Edit")
}
}
.tint(.blue)
}
}
.animation(
.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1),
value: animationKey
)
.onChange(of: problems) {
animationKey += 1
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.hidden)
.clipped()
.alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) {
Button("Cancel", role: .cancel) {
problemToDelete = nil
}
Button("Delete", role: .destructive) {
if let problem = problemToDelete {
dataManager.deleteProblem(problem)
problemToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this problem? This will also delete all associated attempts."
)
}
.sheet(item: $problemToEdit) { problem in
AddEditProblemView(problemId: problem.id)
}
}
}
struct ProblemRow: View {
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
private var gym: Gym? {
dataManager.gym(withId: problem.gymId)
}
private var isCompleted: Bool {
dataManager.attempts.contains { attempt in
attempt.problemId == problem.id && attempt.result.isSuccessful
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(problem.isActive ? .primary : .secondary)
Text(gym?.name ?? "Unknown Gym")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
HStack(spacing: 8) {
if !problem.imagePaths.isEmpty {
Image(systemName: "photo")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.blue)
}
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.green)
}
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.isActive {
Text("Reset / No Longer Set")
.font(.caption)
.foregroundColor(.orange)
.fontWeight(.medium)
}
}
.padding(.vertical, 8)
}
}
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,284 @@
import Combine
import SwiftUI
struct SessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
var body: some View {
NavigationStack {
Group {
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
EmptySessionsView()
} else {
SessionsList()
}
}
.navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
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 SessionsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var sessionToDelete: ClimbSession?
private var completedSessions: [ClimbSession] {
dataManager.sessions
.filter { $0.status == .completed }
.sorted { $0.date > $1.date }
}
var body: some View {
List {
// Active session banner section
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
Section {
ActiveSessionBanner(session: activeSession, gym: gym)
.padding(.horizontal, 16)
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
// Completed sessions section
if !completedSessions.isEmpty {
Section {
ForEach(completedSessions) { session in
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
SessionRow(session: session)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
sessionToDelete = session
} label: {
Label("Delete", systemImage: "trash")
}
}
}
} header: {
if dataManager.activeSession != nil {
Text("Previous Sessions")
.font(.headline)
.fontWeight(.semibold)
}
}
}
}
.listStyle(.insetGrouped)
.alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) {
Button("Cancel", role: .cancel) {
sessionToDelete = nil
}
Button("Delete", role: .destructive) {
if let session = sessionToDelete {
dataManager.deleteSession(session)
sessionToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this session? This will also delete all attempts associated with this session."
)
}
}
}
struct ActiveSessionBanner: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var navigateToDetail = false
var body: some View {
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(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
navigateToDetail = true
}
Button(action: {
dataManager.endSession(session.id)
}) {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(Color.red)
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
.navigationDestination(isPresented: $navigateToDetail) {
SessionDetailView(sessionId: session.id)
}
}
}
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, 8)
}
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,998 @@
import HealthKit
import SwiftUI
import UniformTypeIdentifiers
enum SheetType {
case export(Data)
case importData
}
struct SettingsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var activeSheet: SheetType?
var body: some View {
NavigationStack {
List {
SyncSection()
.environmentObject(dataManager.syncService)
HealthKitSection()
.environmentObject(dataManager.healthKitService)
DataManagementSection(
activeSheet: $activeSheet
)
AppInfoSection()
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(0.6)
}
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Circle()
.fill(.regularMaterial)
)
.transition(.scale.combined(with: .opacity))
.animation(
.easeInOut(duration: 0.2), value: dataManager.isSyncing
)
}
}
}
.sheet(
item: Binding<SheetType?>(
get: { activeSheet },
set: { activeSheet = $0 }
)
) { sheetType in
switch sheetType {
case .export(let data):
ExportDataView(data: data)
case .importData:
ImportDataView()
}
}
}
}
}
extension SheetType: Identifiable {
var id: String {
switch self {
case .export: return "export"
case .importData: return "import"
}
}
}
struct DataManagementSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@Binding var activeSheet: SheetType?
@State private var showingResetAlert = false
@State private var isExporting = false
@State private var isDeletingImages = false
@State private var showingDeleteImagesAlert = 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: {
activeSheet = .importData
}) {
HStack {
Image(systemName: "square.and.arrow.down")
.foregroundColor(.green)
Text("Import Data")
Spacer()
}
}
.foregroundColor(.primary)
// Delete All Images
Button(action: {
showingDeleteImagesAlert = true
}) {
HStack {
if isDeletingImages {
ProgressView()
.scaleEffect(0.8)
Text("Deleting Images...")
.foregroundColor(.secondary)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Delete All Images")
}
Spacer()
}
}
.disabled(isDeletingImages)
.foregroundColor(.red)
// 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."
)
}
.alert("Delete All Images", isPresented: $showingDeleteImagesAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
deleteAllImages()
}
} message: {
Text(
"This will permanently delete ALL image files from your device.\n\nProblems will keep their references but the actual image files will be removed. This cannot be undone.\n\nConsider exporting your data first if you want to keep your images."
)
}
}
private func exportDataAsync() {
isExporting = true
Task {
let data = await MainActor.run { dataManager.exportData() }
isExporting = false
if let data = data {
activeSheet = .export(data)
}
}
}
private func deleteAllImages() {
isDeletingImages = true
Task {
await MainActor.run {
deleteAllImageFiles()
isDeletingImages = false
dataManager.successMessage = "All images deleted successfully!"
}
}
}
private func deleteAllImageFiles() {
let imageManager = ImageManager.shared
let fileManager = FileManager.default
// Delete all images from the images directory
let imagesDir = imageManager.imagesDirectory
do {
let imageFiles = try fileManager.contentsOfDirectory(
at: imagesDir, includingPropertiesForKeys: nil)
var deletedCount = 0
for imageFile in imageFiles {
do {
try fileManager.removeItem(at: imageFile)
deletedCount += 1
} catch {
print("Failed to delete image: \(imageFile.lastPathComponent)")
}
}
print("Deleted \(deletedCount) image files")
} catch {
print("Failed to access images directory: \(error)")
}
// Delete all images from backup directory
let backupDir = imageManager.backupDirectory
do {
let backupFiles = try fileManager.contentsOfDirectory(
at: backupDir, includingPropertiesForKeys: nil)
for backupFile in backupFiles {
try? fileManager.removeItem(at: backupFile)
}
} catch {
print("Failed to access backup directory: \(error)")
}
// Clear image paths from all problems
let updatedProblems = dataManager.problems.map { problem in
problem.updated(imagePaths: [])
}
for problem in updatedProblems {
dataManager.updateProblem(problem)
}
}
}
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("AppLogo")
.resizable()
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Ascently")
.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)
}
}
}
}
struct ExportDataView: View {
let data: Data
@Environment(\.dismiss) private var dismiss
@State private var tempFileURL: URL?
@State private var isCreatingFile = true
var body: some View {
NavigationStack {
VStack(spacing: 30) {
if isCreatingFile {
// Loading state - more prominent
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
.tint(.blue)
Text("Preparing Your Export")
.font(.title2)
.fontWeight(.semibold)
Text("Creating ZIP file with your climbing data and images...")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// Ready state
VStack(spacing: 20) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Export Ready!")
.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(
"Ascently Data Export",
image: Image("AppLogo"))
) {
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)
}
}
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 = "ascently_export_\(timestamp).zip"
guard
let documentsURL = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask
).first
else {
print("Could not access Documents directory")
DispatchQueue.main.async {
self.isCreatingFile = false
}
return
}
let fileURL = documentsURL.appendingPathComponent(filename)
// Write the ZIP data to the file
try data.write(to: fileURL)
DispatchQueue.main.async {
self.tempFileURL = fileURL
self.isCreatingFile = false
}
} catch {
print("Failed to create export file: \(error)")
DispatchQueue.main.async {
self.isCreatingFile = false
}
}
}
}
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 SyncSection: View {
@EnvironmentObject var syncService: SyncService
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingSyncSettings = false
@State private var showingDisconnectAlert = false
var body: some View {
Section("Sync") {
// Sync Status
HStack {
Image(
systemName: syncService.isConnected
? "checkmark.circle.fill"
: syncService.isConfigured
? "exclamationmark.triangle.fill"
: "exclamationmark.circle.fill"
)
.foregroundColor(
syncService.isConnected
? .green
: syncService.isConfigured
? .orange
: .red
)
VStack(alignment: .leading) {
Text("Sync Server")
.font(.headline)
Text(
syncService.isConnected
? "Connected"
: syncService.isConfigured
? "Configured - Not tested"
: "Not configured"
)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
// Configure Server
Button(action: {
showingSyncSettings = true
}) {
HStack {
Image(systemName: "gear")
.foregroundColor(.blue)
Text("Configure Server")
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.foregroundColor(.primary)
if syncService.isConfigured {
// Sync Now - only show if connected
if syncService.isConnected {
Button(action: {
performSync()
}) {
HStack {
if syncService.isSyncing {
ProgressView()
.scaleEffect(0.8)
Text("Syncing...")
.foregroundColor(.secondary)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundColor(.green)
Text("Sync Now")
Spacer()
if let lastSync = syncService.lastSyncTime {
Text(
RelativeDateTimeFormatter().localizedString(
for: lastSync, relativeTo: Date())
)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.disabled(syncService.isSyncing)
.foregroundColor(.primary)
}
// Auto-sync configuration - always visible for testing
HStack {
VStack(alignment: .leading) {
Text("Auto-sync")
Text("Sync automatically on app launch and data changes")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: { syncService.isAutoSyncEnabled },
set: { syncService.isAutoSyncEnabled = $0 }
)
)
.disabled(!syncService.isConnected)
}
.foregroundColor(.primary)
// Disconnect option - only show if connected
if syncService.isConnected {
Button(action: {
showingDisconnectAlert = true
}) {
HStack {
Image(systemName: "power")
.foregroundColor(.orange)
Text("Disconnect")
Spacer()
}
}
.foregroundColor(.primary)
}
if let error = syncService.syncError {
Text(error)
.font(.caption)
.foregroundColor(.red)
.padding(.leading, 24)
}
}
}
.sheet(isPresented: $showingSyncSettings) {
SyncSettingsView()
.environmentObject(syncService)
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {}
Button("Disconnect", role: .destructive) {
syncService.disconnect()
}
} message: {
Text(
"This will sign you out but keep your server settings. You'll need to test the connection again to sync."
)
}
}
private func performSync() {
Task {
do {
try await syncService.syncWithServer(dataManager: dataManager)
} catch {
print("Sync failed: \(error)")
}
}
}
}
struct SyncSettingsView: View {
@EnvironmentObject var syncService: SyncService
@Environment(\.dismiss) private var dismiss
@State private var serverURL: String = ""
@State private var authToken: String = ""
@State private var showingDisconnectAlert = false
@State private var isTesting = false
@State private var showingTestResult = false
@State private var testResultMessage = ""
var body: some View {
NavigationStack {
Form {
Section {
TextField("Server URL", text: $serverURL)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.placeholder(when: serverURL.isEmpty) {
Text("http://your-server:8080")
.foregroundColor(.secondary)
}
TextField("Auth Token", text: $authToken)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.placeholder(when: authToken.isEmpty) {
Text("your-secret-token")
.foregroundColor(.secondary)
}
} header: {
Text("Server Configuration")
} footer: {
Text(
"Enter your sync server URL and authentication token. You must test the connection before syncing is available."
)
}
Section {
Button(action: {
testConnection()
}) {
HStack {
if isTesting {
ProgressView()
.scaleEffect(0.8)
Text("Testing...")
.foregroundColor(.secondary)
} else {
Image(systemName: "network")
.foregroundColor(.blue)
Text("Test Connection")
Spacer()
if syncService.isConnected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
}
}
.disabled(
isTesting
|| serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
.foregroundColor(.primary)
} header: {
Text("Connection")
} footer: {
Text("Test the connection to verify your server settings before saving.")
}
Section {
Button("Disconnect from Server") {
showingDisconnectAlert = true
}
.foregroundColor(.orange)
Button("Clear Configuration") {
syncService.clearConfiguration()
serverURL = ""
authToken = ""
}
.foregroundColor(.red)
} footer: {
Text(
"Disconnect will sign you out but keep settings. Clear Configuration removes all sync settings."
)
}
}
.navigationTitle("Sync Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
let newURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Mark as disconnected if settings changed
if newURL != syncService.serverURL || newToken != syncService.authToken {
syncService.isConnected = false
UserDefaults.standard.set(false, forKey: "sync_is_connected")
}
syncService.serverURL = newURL
syncService.authToken = newToken
dismiss()
}
.fontWeight(.semibold)
}
}
}
.onAppear {
serverURL = syncService.serverURL
authToken = syncService.authToken
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {}
Button("Disconnect", role: .destructive) {
syncService.disconnect()
dismiss()
}
} message: {
Text(
"This will sign you out but keep your server settings. You'll need to test the connection again to sync."
)
}
.alert("Connection Test", isPresented: $showingTestResult) {
Button("OK") {}
} message: {
Text(testResultMessage)
}
}
private func testConnection() {
isTesting = true
let testURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let testToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Store original values in case test fails
let originalURL = syncService.serverURL
let originalToken = syncService.authToken
Task {
do {
// Temporarily set the values for testing
syncService.serverURL = testURL
syncService.authToken = testToken
try await syncService.testConnection()
await MainActor.run {
isTesting = false
testResultMessage =
"Connection successful! You can now save and sync your data."
showingTestResult = true
}
} catch {
// Restore original values if test failed
syncService.serverURL = originalURL
syncService.authToken = originalToken
await MainActor.run {
isTesting = false
testResultMessage = "Connection failed: \(error.localizedDescription)"
showingTestResult = true
}
}
}
}
}
// Removed AutoSyncSettingsView - now using simple toggle in main settings
extension View {
func placeholder<Content: View>(
when shouldShow: Bool,
alignment: Alignment = .leading,
@ViewBuilder placeholder: () -> Content
) -> some View {
ZStack(alignment: alignment) {
placeholder().opacity(shouldShow ? 1 : 0)
self
}
}
}
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 {
NavigationStack {
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("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
// Auto-close after successful import
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismiss()
}
}
} catch {
await MainActor.run {
isImporting = false
importError = "Import failed: \(error.localizedDescription)"
}
}
}
}
}
struct HealthKitSection: View {
@EnvironmentObject var healthKitService: HealthKitService
@State private var showingAuthorizationError = false
@State private var isRequestingAuthorization = false
var body: some View {
Section {
if !HKHealthStore.isHealthDataAvailable() {
HStack {
Image(systemName: "heart.slash")
.foregroundColor(.secondary)
Text("Apple Health not available")
.foregroundColor(.secondary)
}
} else {
Toggle(
isOn: Binding(
get: { healthKitService.isEnabled },
set: { newValue in
if newValue && !healthKitService.isAuthorized {
isRequestingAuthorization = true
Task {
do {
try await healthKitService.requestAuthorization()
await MainActor.run {
healthKitService.setEnabled(true)
isRequestingAuthorization = false
}
} catch {
await MainActor.run {
showingAuthorizationError = true
isRequestingAuthorization = false
}
}
}
} else if newValue {
healthKitService.setEnabled(true)
} else {
healthKitService.setEnabled(false)
}
}
)
) {
HStack {
Image(systemName: "heart.fill")
.foregroundColor(.red)
Text("Apple Health Integration")
}
}
.disabled(isRequestingAuthorization)
if healthKitService.isEnabled {
VStack(alignment: .leading, spacing: 4) {
Text(
"Climbing sessions will be recorded as workouts in Apple Health"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} header: {
Text("Health")
} footer: {
if healthKitService.isEnabled {
Text(
"Each climbing session will automatically be saved to Apple Health as a \"Climbing\" workout with the session duration."
)
}
}
.alert("Authorization Required", isPresented: $showingAuthorizationError) {
Button("OK", role: .cancel) {}
} message: {
Text(
"Please grant access to Apple Health in Settings to enable this feature."
)
}
}
}
#Preview {
SettingsView()
.environmentObject(ClimbingDataManager.preview)
}