Moved to Ascently
All checks were successful
Ascently Docker Deploy / build-and-push (push) Successful in 2m31s
All checks were successful
Ascently Docker Deploy / build-and-push (push) Successful in 2m31s
This commit is contained in:
1326
ios/Ascently/Views/AddEdit/AddAttemptView.swift
Normal file
1326
ios/Ascently/Views/AddEdit/AddAttemptView.swift
Normal file
File diff suppressed because it is too large
Load Diff
209
ios/Ascently/Views/AddEdit/AddEditGymView.swift
Normal file
209
ios/Ascently/Views/AddEdit/AddEditGymView.swift
Normal 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)
|
||||
}
|
||||
637
ios/Ascently/Views/AddEdit/AddEditProblemView.swift
Normal file
637
ios/Ascently/Views/AddEdit/AddEditProblemView.swift
Normal 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)
|
||||
}
|
||||
136
ios/Ascently/Views/AddEdit/AddEditSessionView.swift
Normal file
136
ios/Ascently/Views/AddEdit/AddEditSessionView.swift
Normal 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)
|
||||
}
|
||||
547
ios/Ascently/Views/AnalyticsView.swift
Normal file
547
ios/Ascently/Views/AnalyticsView.swift
Normal 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)
|
||||
}
|
||||
425
ios/Ascently/Views/Detail/GymDetailView.swift
Normal file
425
ios/Ascently/Views/Detail/GymDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
468
ios/Ascently/Views/Detail/ProblemDetailView.swift
Normal file
468
ios/Ascently/Views/Detail/ProblemDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
452
ios/Ascently/Views/Detail/SessionDetailView.swift
Normal file
452
ios/Ascently/Views/Detail/SessionDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
218
ios/Ascently/Views/GymsView.swift
Normal file
218
ios/Ascently/Views/GymsView.swift
Normal 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)
|
||||
}
|
||||
278
ios/Ascently/Views/LiveActivityDebugView.swift
Normal file
278
ios/Ascently/Views/LiveActivityDebugView.swift
Normal 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)
|
||||
}
|
||||
530
ios/Ascently/Views/ProblemsView.swift
Normal file
530
ios/Ascently/Views/ProblemsView.swift
Normal 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)
|
||||
}
|
||||
284
ios/Ascently/Views/SessionsView.swift
Normal file
284
ios/Ascently/Views/SessionsView.swift
Normal 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)
|
||||
}
|
||||
998
ios/Ascently/Views/SettingsView.swift
Normal file
998
ios/Ascently/Views/SettingsView.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user