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) }