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 private var filteredProblems: [Problem] { var filtered = dataManager.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 let active = filtered.filter { $0.isActive }.sorted { $0.updatedAt > $1.updatedAt } let inactive = filtered.filter { !$0.isActive }.sorted { $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: filteredProblems ) .padding() .background(.regularMaterial) } if filteredProblems.isEmpty { EmptyProblemsView( isEmpty: dataManager.problems.isEmpty, isFiltered: !dataManager.problems.isEmpty ) } else { ProblemsList(problems: filteredProblems) } } } .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() } } } } 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? var body: some View { List(problems) { 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 { 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) } } .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) } 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) { Text(problem.difficulty.grade) .font(.title2) .fontWeight(.bold) .foregroundColor(.blue) Text(problem.climbType.displayName) .font(.caption) .foregroundColor(.secondary) } } if let location = problem.location { Text("Location: \(location)") .font(.caption) .foregroundColor(.secondary) } if !problem.tags.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(problem.tags.prefix(3), id: \.self) { tag in Text(tag) .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 4) .fill(.blue.opacity(0.1)) ) .foregroundColor(.blue) } } } } if !problem.imagePaths.isEmpty { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 8) { ForEach(problem.imagePaths.prefix(3), id: \.self) { imagePath in ProblemImageView(imagePath: imagePath) } } .padding(.horizontal, 4) } } 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." } } } struct ProblemImageView: View { let imagePath: String @State private var uiImage: UIImage? @State private var isLoading = true @State private var hasFailed = false private static var imageCache: NSCache = { let cache = NSCache() cache.countLimit = 100 cache.totalCostLimit = 50 * 1024 * 1024 // 50MB return cache }() var body: some View { Group { if let uiImage = uiImage { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 60, height: 60) .clipped() .cornerRadius(8) } else if hasFailed { RoundedRectangle(cornerRadius: 8) .fill(.gray.opacity(0.2)) .frame(width: 60, height: 60) .overlay { Image(systemName: "photo") .foregroundColor(.gray) .font(.title3) } } else { RoundedRectangle(cornerRadius: 8) .fill(.gray.opacity(0.3)) .frame(width: 60, height: 60) .overlay { ProgressView() .scaleEffect(0.8) } } } .onAppear { loadImage() } } private func loadImage() { guard !imagePath.isEmpty else { hasFailed = true isLoading = false return } let cacheKey = NSString(string: imagePath) // Check cache first if let cachedImage = Self.imageCache.object(forKey: cacheKey) { self.uiImage = cachedImage self.isLoading = false return } DispatchQueue.global(qos: .userInitiated).async { if let data = ImageManager.shared.loadImageData(fromPath: imagePath), let image = UIImage(data: data) { // Cache the image Self.imageCache.setObject(image, forKey: cacheKey) DispatchQueue.main.async { self.uiImage = image self.isLoading = false } } else { DispatchQueue.main.async { self.hasFailed = true self.isLoading = false } } } } } #Preview { ProblemsView() .environmentObject(ClimbingDataManager.preview) }