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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user