Files
Ascently/ios/Ascently/Views/ProblemsView.swift

616 lines
23 KiB
Swift

import SwiftUI
struct ProblemsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@State private var showingAddProblem = false
@State private var selectedClimbType: ClimbType?
@State private var selectedGym: Gym?
@State private var searchText = ""
@State private var showingSearch = false
@State private var showingFilters = false
@FocusState private var isSearchFocused: Bool
@State private var cachedFilteredProblems: [Problem] = []
// State moved from ProblemsList
@State private var problemToDelete: Problem?
@State private var problemToEdit: Problem?
@State private var animationKey = 0
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 {
if cachedFilteredProblems.isEmpty {
VStack(spacing: 0) {
headerContent
EmptyProblemsView(
isEmpty: dataManager.problems.isEmpty,
isFiltered: !dataManager.problems.isEmpty
)
}
} else {
List {
if showingSearch {
Section {
headerContent
}
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
ForEach(cachedFilteredProblems) { 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")
}
.tint(.red)
Button {
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(.indigo)
}
}
}
.listStyle(.plain)
.animation(
.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1),
value: animationKey
)
}
}
.navigationTitle("Problems")
.navigationBarTitleDisplayMode(.automatic)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if dataManager.isSyncing {
HStack(spacing: 2) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: themeManager.accentColor))
.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 : themeManager.accentColor)
}
Button(action: {
showingFilters = true
}) {
let hasFilters = selectedClimbType != nil || selectedGym != nil
Image(systemName: hasFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
.font(.system(size: 16, weight: .medium))
.foregroundColor(themeManager.accentColor)
}
if !dataManager.gyms.isEmpty {
Button("Add") {
showingAddProblem = true
}
}
}
}
.sheet(isPresented: $showingAddProblem) {
AddEditProblemView()
}
.sheet(isPresented: $showingFilters) {
FilterSheet(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: cachedFilteredProblems
)
.presentationDetents([.height(320)])
}
.sheet(item: $problemToEdit) { problem in
AddEditProblemView(problemId: problem.id)
}
.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."
)
}
}
.onAppear {
updateFilteredProblems()
}
.onChange(of: dataManager.problems) {
updateFilteredProblems()
}
.onChange(of: searchText) {
updateFilteredProblems()
}
.onChange(of: selectedClimbType) {
updateFilteredProblems()
}
.onChange(of: selectedGym) {
updateFilteredProblems()
}
.onChange(of: cachedFilteredProblems) {
animationKey += 1
}
}
@ViewBuilder
private var headerContent: some View {
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)
.padding(.bottom, 8)
.animation(.easeInOut(duration: 0.3), value: showingSearch)
}
}
}
}
struct FilterSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@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
@EnvironmentObject var themeManager: ThemeManager
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 ? themeManager.accentColor : .clear)
.stroke(themeManager.accentColor, lineWidth: 1)
)
.foregroundColor(isSelected ? themeManager.contrastingTextColor : themeManager.accentColor)
}
.buttonStyle(.plain)
}
}
struct ProblemRow: View {
let problem: Problem
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
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(themeManager.accentColor)
}
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.green)
}
Text(problem.difficulty.grade)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(themeManager.accentColor)
}
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(themeManager.accentColor.opacity(0.1))
)
.foregroundColor(themeManager.accentColor)
}
}
}
}
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 FilterSheet: View {
@Binding var selectedClimbType: ClimbType?
@Binding var selectedGym: Gym?
let filteredProblems: [Problem]
@Environment(\.dismiss) var dismiss
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
NavigationStack {
ScrollView {
FilterSection(
selectedClimbType: $selectedClimbType,
selectedGym: $selectedGym,
filteredProblems: filteredProblems
)
.padding()
}
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
dismiss()
}) {
Text("Done")
.font(.subheadline)
.fontWeight(.semibold)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Color.secondary.opacity(0.2), lineWidth: 0.5)
)
.foregroundColor(themeManager.accentColor)
}
}
ToolbarItem(placement: .navigationBarLeading) {
if selectedClimbType != nil || selectedGym != nil {
Button(action: {
selectedClimbType = nil
selectedGym = nil
}) {
Text("Reset")
.font(.subheadline)
.fontWeight(.medium)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Color.secondary.opacity(0.2), lineWidth: 0.5)
)
.foregroundColor(.red)
}
}
}
}
}
}
}
#Preview {
ProblemsView()
.environmentObject(ClimbingDataManager.preview)
}