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