1.5.0 Initial run as iOS in a monorepo
This commit is contained in:
430
ios/OpenClimb/Views/Detail/GymDetailView.swift
Normal file
430
ios/OpenClimb/Views/Detail/GymDetailView.swift
Normal file
@@ -0,0 +1,430 @@
|
||||
//
|
||||
// GymDetailView.swift
|
||||
// OpenClimb
|
||||
//
|
||||
// Created by OpenClimb on 2025-01-17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GymDetailView: View {
|
||||
let gymId: UUID
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingDeleteAlert = false
|
||||
|
||||
private var gym: Gym? {
|
||||
dataManager.gym(withId: gymId)
|
||||
}
|
||||
|
||||
private var problems: [Problem] {
|
||||
dataManager.problems(forGym: gymId)
|
||||
}
|
||||
|
||||
private var sessions: [ClimbSession] {
|
||||
dataManager.sessions(forGym: gymId)
|
||||
}
|
||||
|
||||
private var gymAttempts: [Attempt] {
|
||||
let problemIds = Set(problems.map { $0.id })
|
||||
return dataManager.attempts.filter { problemIds.contains($0.problemId) }
|
||||
}
|
||||
|
||||
private var gymStats: GymStats {
|
||||
calculateGymStats()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 20) {
|
||||
if let gym = gym {
|
||||
GymHeaderCard(gym: gym)
|
||||
|
||||
GymStatsCard(stats: gymStats)
|
||||
|
||||
if !problems.isEmpty {
|
||||
RecentProblemsSection(problems: problems.prefix(5))
|
||||
}
|
||||
|
||||
if !sessions.isEmpty {
|
||||
RecentSessionsSection(sessions: sessions.prefix(3))
|
||||
}
|
||||
|
||||
if problems.isEmpty && sessions.isEmpty {
|
||||
EmptyGymStateView()
|
||||
}
|
||||
} else {
|
||||
Text("Gym not found")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(gym?.name ?? "Gym Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
if gym != nil {
|
||||
Menu {
|
||||
Button("Edit Gym") {
|
||||
// Navigate to edit view
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showingDeleteAlert = true
|
||||
} label: {
|
||||
Label("Delete Gym", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Delete Gym", isPresented: $showingDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete", role: .destructive) {
|
||||
if let gym = gym {
|
||||
dataManager.deleteGym(gym)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(
|
||||
"Are you sure you want to delete this gym? This will also delete all problems and sessions associated with this gym."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateGymStats() -> GymStats {
|
||||
let uniqueProblemsClimbed = Set(gymAttempts.map { $0.problemId }).count
|
||||
let totalSessions = sessions.count
|
||||
let activeSessions = sessions.count { $0.status == .active }
|
||||
|
||||
return GymStats(
|
||||
totalProblems: problems.count,
|
||||
totalSessions: totalSessions,
|
||||
totalAttempts: gymAttempts.count,
|
||||
uniqueProblemsClimbed: uniqueProblemsClimbed,
|
||||
activeSessions: activeSessions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GymHeaderCard: View {
|
||||
let gym: Gym
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(gym.name)
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
|
||||
if let location = gym.location, !location.isEmpty {
|
||||
Text(location)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let notes = gym.notes, !notes.isEmpty {
|
||||
Text(notes)
|
||||
.font(.body)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Supported Climb Types
|
||||
if !gym.supportedClimbTypes.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Climb Types")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(gym.supportedClimbTypes, id: \.self) { climbType in
|
||||
Text(climbType.displayName)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.blue.opacity(0.1))
|
||||
)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Difficulty Systems
|
||||
if !gym.difficultySystems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Difficulty Systems")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text(gym.difficultySystems.map { $0.displayName }.joined(separator: ", "))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GymStatsCard: View {
|
||||
let stats: GymStats
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Statistics")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
|
||||
StatItem(label: "Problems", value: "\(stats.totalProblems)")
|
||||
StatItem(label: "Sessions", value: "\(stats.totalSessions)")
|
||||
StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
|
||||
StatItem(label: "Problems Climbed", value: "\(stats.uniqueProblemsClimbed)")
|
||||
}
|
||||
|
||||
if stats.activeSessions > 0 {
|
||||
HStack {
|
||||
StatItem(label: "Active Sessions", value: "\(stats.activeSessions)")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentProblemsSection: View {
|
||||
let problems: any Sequence<Problem>
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(
|
||||
"Problems (\(dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count))"
|
||||
)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(Array(problems), id: \.id) { problem in
|
||||
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
|
||||
ProblemRowCard(problem: problem)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
if dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count > 5 {
|
||||
Text(
|
||||
"... and \(dataManager.problems(forGym: Array(problems).first?.gymId ?? UUID()).count - 5) more problems"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentSessionsSection: View {
|
||||
let sessions: any Sequence<ClimbSession>
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(
|
||||
"Recent Sessions (\(dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count))"
|
||||
)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(Array(sessions), id: \.id) { session in
|
||||
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
|
||||
SessionRowCard(session: session)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
if dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count > 3 {
|
||||
Text(
|
||||
"... and \(dataManager.sessions(forGym: Array(sessions).first?.gymId ?? UUID()).count - 3) more sessions"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProblemRowCard: View {
|
||||
let problem: Problem
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
|
||||
private var problemAttempts: [Attempt] {
|
||||
dataManager.attempts(forProblem: problem.id)
|
||||
}
|
||||
|
||||
private var isCompleted: Bool {
|
||||
problemAttempts.contains { $0.result.isSuccessful }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(problem.name ?? "Unnamed Problem")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(
|
||||
"\(problem.difficulty.grade) • \(problem.climbType.displayName) • \(problemAttempts.count) attempts"
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isCompleted {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.ultraThinMaterial)
|
||||
.stroke(.quaternary, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionRowCard: View {
|
||||
let session: ClimbSession
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
|
||||
private var sessionAttempts: [Attempt] {
|
||||
dataManager.attempts(forSession: session.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(session.status == .active ? "Active Session" : "Session")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if session.status == .active {
|
||||
Text("ACTIVE")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(.green.opacity(0.2))
|
||||
)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(formatDate(session.date)) • \(sessionAttempts.count) attempts")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let duration = session.duration {
|
||||
Text("\(duration)min")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.ultraThinMaterial)
|
||||
.stroke(.quaternary, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyGymStateView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "figure.climbing")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("No activity yet")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Start a session or add problems to see them here")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding(40)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GymStats {
|
||||
let totalProblems: Int
|
||||
let totalSessions: Int
|
||||
let totalAttempts: Int
|
||||
let uniqueProblemsClimbed: Int
|
||||
let activeSessions: Int
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
GymDetailView(gymId: UUID())
|
||||
.environmentObject(ClimbingDataManager.preview)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user