1.5.0 Initial run as iOS in a monorepo

This commit is contained in:
2025-09-12 22:35:14 -06:00
parent ba6edcd854
commit ce220c7220
127 changed files with 7062 additions and 1039 deletions

View 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)
}
}