Files
Ascently/ios/OpenClimb/Views/SessionsView.swift

285 lines
9.4 KiB
Swift

import Combine
import SwiftUI
struct SessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
var body: some View {
NavigationStack {
Group {
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
EmptySessionsView()
} else {
SessionsList()
}
}
.navigationTitle("Sessions")
.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
)
}
if dataManager.gyms.isEmpty {
EmptyView()
} else if dataManager.activeSession == nil {
Button("Start Session") {
if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id)
} else {
showingAddSession = true
}
}
}
}
}
.sheet(isPresented: $showingAddSession) {
AddEditSessionView()
}
}
}
}
struct SessionsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var sessionToDelete: ClimbSession?
private var completedSessions: [ClimbSession] {
dataManager.sessions
.filter { $0.status == .completed }
.sorted { $0.date > $1.date }
}
var body: some View {
List {
// Active session banner section
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
Section {
ActiveSessionBanner(session: activeSession, gym: gym)
.padding(.horizontal, 16)
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
// Completed sessions section
if !completedSessions.isEmpty {
Section {
ForEach(completedSessions) { session in
NavigationLink(destination: SessionDetailView(sessionId: session.id)) {
SessionRow(session: session)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
sessionToDelete = session
} label: {
Label("Delete", systemImage: "trash")
}
}
}
} header: {
if dataManager.activeSession != nil {
Text("Previous Sessions")
.font(.headline)
.fontWeight(.semibold)
}
}
}
}
.listStyle(.insetGrouped)
.alert("Delete Session", isPresented: .constant(sessionToDelete != nil)) {
Button("Cancel", role: .cancel) {
sessionToDelete = nil
}
Button("Delete", role: .destructive) {
if let session = sessionToDelete {
dataManager.deleteSession(session)
sessionToDelete = nil
}
}
} message: {
Text(
"Are you sure you want to delete this session? This will also delete all attempts associated with this session."
)
}
}
}
struct ActiveSessionBanner: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var navigateToDetail = false
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "play.fill")
.foregroundColor(.green)
.font(.caption)
Text("Active Session")
.font(.headline)
.fontWeight(.bold)
}
Text(gym.name)
.font(.subheadline)
.foregroundColor(.secondary)
if let startTime = session.startTime {
Text(timerInterval: startTime...Date.distantFuture, countsDown: false)
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
navigateToDetail = true
}
Button(action: {
dataManager.endSession(session.id)
}) {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(Color.red)
.clipShape(Circle())
}
.buttonStyle(PlainButtonStyle())
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
.navigationDestination(isPresented: $navigateToDetail) {
SessionDetailView(sessionId: session.id)
}
}
}
struct SessionRow: View {
let session: ClimbSession
@EnvironmentObject var dataManager: ClimbingDataManager
private var gym: Gym? {
dataManager.gym(withId: session.gymId)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(gym?.name ?? "Unknown Gym")
.font(.headline)
.fontWeight(.semibold)
Spacer()
Text(formatDate(session.date))
.font(.caption)
.foregroundColor(.secondary)
}
if let duration = session.duration {
Text("Duration: \(duration) minutes")
.font(.subheadline)
.foregroundColor(.secondary)
}
if let notes = session.notes, !notes.isEmpty {
Text(notes)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 8)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
struct EmptySessionsView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingAddSession = false
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "figure.climbing")
.font(.system(size: 60))
.foregroundColor(.secondary)
VStack(spacing: 8) {
Text(dataManager.gyms.isEmpty ? "No Gyms Available" : "No Sessions Yet")
.font(.title2)
.fontWeight(.bold)
Text(
dataManager.gyms.isEmpty
? "Add a gym first to start tracking your climbing sessions!"
: "Start your first climbing session!"
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
if !dataManager.gyms.isEmpty {
Button("Start Session") {
if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id)
} else {
showingAddSession = true
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Spacer()
}
.sheet(isPresented: $showingAddSession) {
AddEditSessionView()
}
}
}
#Preview {
SessionsView()
.environmentObject(ClimbingDataManager.preview)
}