Files
Ascently/ios/OpenClimb/Views/Detail/SessionDetailView.swift

453 lines
16 KiB
Swift

import Combine
import SwiftUI
struct SessionDetailView: View {
let sessionId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
@State private var showingAddAttempt = false
@State private var editingAttempt: Attempt?
@State private var attemptToDelete: Attempt?
private var session: ClimbSession? {
dataManager.session(withId: sessionId)
}
private var gym: Gym? {
guard let session = session else { return nil }
return dataManager.gym(withId: session.gymId)
}
private var attempts: [Attempt] {
dataManager.attempts(forSession: sessionId)
}
private var attemptsWithProblems: [(Attempt, Problem)] {
attempts.compactMap { attempt in
guard let problem = dataManager.problem(withId: attempt.problemId) else { return nil }
return (attempt, problem)
}.sorted { $0.0.timestamp < $1.0.timestamp }
}
private var sessionStats: SessionStats {
calculateSessionStats()
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if let session = session, let gym = gym {
SessionHeaderCard(
session: session, gym: gym, stats: sessionStats)
SessionStatsCard(stats: sessionStats)
AttemptsSection(
attemptsWithProblems: attemptsWithProblems,
attemptToDelete: $attemptToDelete,
editingAttempt: $editingAttempt)
} else {
Text("Session not found")
.foregroundColor(.secondary)
}
}
.padding()
}
.navigationTitle("Session Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if let session = session {
if session.status == .active {
Button("End Session") {
dataManager.endSession(session.id)
dismiss()
}
.foregroundColor(.orange)
} else {
Menu {
Button(role: .destructive) {
showingDeleteAlert = true
} label: {
Label("Delete Session", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
}
.alert(
"Delete Attempt",
isPresented: Binding<Bool>(
get: { attemptToDelete != nil },
set: { if !$0 { attemptToDelete = nil } }
)
) {
Button("Cancel", role: .cancel) {
attemptToDelete = nil
}
Button("Delete", role: .destructive) {
if let attempt = attemptToDelete {
dataManager.deleteAttempt(attempt)
attemptToDelete = nil
}
}
} message: {
if let attempt = attemptToDelete,
let problem = dataManager.problem(withId: attempt.problemId)
{
Text(
"Are you sure you want to delete this attempt on \"\(problem.name ?? "Unknown Problem")\"? This action cannot be undone."
)
} else {
Text("Are you sure you want to delete this attempt? This action cannot be undone.")
}
}
.overlay(alignment: .bottomTrailing) {
if session?.status == .active {
Button(action: { showingAddAttempt = true }) {
Image(systemName: "plus")
.font(.title2)
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Circle().fill(.blue))
.shadow(radius: 4)
}
.padding()
}
}
.alert("Delete Session", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
if let session = session {
dataManager.deleteSession(session)
dismiss()
}
}
} message: {
Text(
"Are you sure you want to delete this session? This will also delete all attempts associated with this session."
)
}
.sheet(isPresented: $showingAddAttempt) {
if let session = session, let gym = gym {
AddAttemptView(session: session, gym: gym)
}
}
.sheet(item: $editingAttempt) { attempt in
EditAttemptView(attempt: attempt)
}
}
private func calculateSessionStats() -> SessionStats {
let successfulAttempts = attempts.filter { $0.result.isSuccessful }
let uniqueProblems = Set(attempts.map { $0.problemId })
let completedProblems = Set(successfulAttempts.map { $0.problemId })
return SessionStats(
totalAttempts: attempts.count,
successfulAttempts: successfulAttempts.count,
uniqueProblemsAttempted: uniqueProblems.count,
uniqueProblemsCompleted: completedProblems.count
)
}
}
struct SessionHeaderCard: View {
let session: ClimbSession
let gym: Gym
let stats: SessionStats
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(gym.name)
.font(.title)
.fontWeight(.bold)
Text(formatDate(session.date))
.font(.title2)
.foregroundColor(.blue)
if session.status == .active {
if let startTime = session.startTime {
Text("Duration: ")
.font(.subheadline)
.foregroundColor(.secondary)
+ Text(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.subheadline)
.foregroundColor(.secondary)
.monospacedDigit()
}
} else if let duration = session.duration {
Text("Duration: \(duration) minutes")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let notes = session.notes, !notes.isEmpty {
Text(notes)
.font(.body)
.padding(.top, 4)
}
}
// Status indicator
HStack {
Image(systemName: session.status == .active ? "play.fill" : "checkmark.circle.fill")
.foregroundColor(session.status == .active ? .green : .blue)
Text(session.status == .active ? "In Progress" : "Completed")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(session.status == .active ? .green : .blue)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill((session.status == .active ? Color.green : Color.blue).opacity(0.1))
)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .full
return formatter.string(from: date)
}
}
struct SessionStatsCard: View {
let stats: SessionStats
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Session Stats")
.font(.title2)
.fontWeight(.bold)
if stats.totalAttempts == 0 {
Text("No attempts recorded yet")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
StatItem(label: "Total Attempts", value: "\(stats.totalAttempts)")
StatItem(label: "Problems", value: "\(stats.uniqueProblemsAttempted)")
StatItem(label: "Completed", value: "\(stats.uniqueProblemsCompleted)")
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
}
}
struct StatItem: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
}
}
struct AttemptsSection: View {
let attemptsWithProblems: [(Attempt, Problem)]
@Binding var attemptToDelete: Attempt?
@Binding var editingAttempt: Attempt?
@EnvironmentObject var dataManager: ClimbingDataManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Attempts (\(attemptsWithProblems.count))")
.font(.title2)
.fontWeight(.bold)
if attemptsWithProblems.isEmpty {
VStack(spacing: 12) {
Image(systemName: "hand.raised.slash")
.font(.title)
.foregroundColor(.secondary)
Text("No attempts yet")
.font(.headline)
.foregroundColor(.secondary)
Text("Start attempting problems to see your progress!")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
)
} else {
List {
ForEach(attemptsWithProblems.indices, id: \.self) { index in
let (attempt, problem) = attemptsWithProblems[index]
AttemptCard(attempt: attempt, problem: problem)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
// Add haptic feedback for delete action
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.impactOccurred()
attemptToDelete = attempt
} label: {
Label("Delete", systemImage: "trash")
}
.accessibilityLabel("Delete attempt")
.accessibilityHint("Removes this attempt from the session")
Button {
editingAttempt = attempt
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
.accessibilityLabel("Edit attempt")
.accessibilityHint("Modify the details of this attempt")
}
.onTapGesture {
editingAttempt = attempt
}
}
}
.listStyle(.plain)
.scrollDisabled(true)
.frame(height: CGFloat(attemptsWithProblems.count) * 120)
}
}
}
}
struct AttemptCard: View {
let attempt: Attempt
let problem: Problem
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unknown Problem")
.font(.headline)
.fontWeight(.semibold)
Text("\(problem.difficulty.system.displayName): \(problem.difficulty.grade)")
.font(.subheadline)
.foregroundColor(.blue)
if let location = problem.location {
Text(location)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
AttemptResultBadge(result: attempt.result)
}
}
if let notes = attempt.notes, !notes.isEmpty {
Text(notes)
.font(.subheadline)
.foregroundColor(.secondary)
}
if let highestHold = attempt.highestHold, !highestHold.isEmpty {
Text("Highest hold: \(highestHold)")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(.regularMaterial)
.cornerRadius(12)
.shadow(radius: 2)
}
}
struct AttemptResultBadge: View {
let result: AttemptResult
private var badgeColor: Color {
switch result {
case .success, .flash:
return .green
case .fall:
return .orange
case .noProgress:
return .red
}
}
var body: some View {
Text(result.displayName)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(badgeColor.opacity(0.1))
)
.foregroundColor(badgeColor)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(badgeColor.opacity(0.3), lineWidth: 1)
)
}
}
struct SessionStats {
let totalAttempts: Int
let successfulAttempts: Int
let uniqueProblemsAttempted: Int
let uniqueProblemsCompleted: Int
}
#Preview {
NavigationView {
SessionDetailView(sessionId: UUID())
.environmentObject(ClimbingDataManager.preview)
}
}