Files
FrenoCorp/Lendair/Views/CommunityEventsView.swift
Michael Freno 57a460761a FRE-4665: Implement Phase 3 AI training plans and premium features
- Models: TrainingPlan, Race, FamilyPlan, BeginnerMode, CommunityEvent
- Services: 5 service layers with protocol-based architecture
- ViewModels: 5 view models with @MainActor ObservableObject pattern
- Views: 10 SwiftUI views for all Phase 3 features
- Updated README with full Phase 3 documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-03 15:21:01 -04:00

237 lines
7.7 KiB
Swift

import SwiftUI
struct CommunityEventsView: View {
@StateObject private var viewModel = CommunityEventViewModel()
@State private var showingCreateSheet = false
@State private var selectedTab: EventTab = .upcoming
enum EventTab: String, CaseIterable {
case upcoming, ongoing, past
}
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.events.isEmpty {
loadingView
} else if currentEvents.isEmpty {
emptyStateView
} else {
eventListView
}
}
.navigationTitle("Community Events")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingCreateSheet = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingCreateSheet) {
CreateEventSheet()
}
}
.onAppear {
Task {
await viewModel.fetchEvents()
}
}
}
private var currentEvents: [CommunityEvent] {
switch selectedTab {
case .upcoming: return viewModel.upcomingEvents
case .ongoing: return viewModel.ongoingEvents
case .past: return viewModel.pastEvents
}
}
private var eventListView: some View {
List {
Picker("Events", selection: $selectedTab) {
ForEach(EventTab.allCases, id: \.self) { tab in
Text(tab.rawValue.capitalized).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
Section(currentSectionTitle) {
ForEach(currentEvents) { event in
NavigationLink(destination: CommunityEventDetailView(event: event)) {
EventRowView(event: event)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await viewModel.fetchEvents()
}
}
private var currentSectionTitle: String {
switch selectedTab {
case .upcoming: return "Upcoming"
case .ongoing: return "Happening Now"
case .past: return "Past Events"
}
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Events...")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 60)
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "person.3.fill")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No \(selectedTab.rawValue) Events")
.font(.title2)
.fontWeight(.semibold)
Text("Create or discover community running events in your area.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
}
struct CreateEventSheet: View {
@Environment(\.dismiss) var dismiss
@State private var title = ""
@State private var description = ""
@State private var eventType: EventType = .groupRun
@State private var location = ""
@State private var distanceKm = ""
@State private var maxParticipants = ""
var body: some View {
NavigationView {
Form {
Section("Event Details") {
TextField("Event Title", text: $title)
TextField("Description", text: $description)
TextField("Location", text: $location)
}
Section("Type") {
Picker("Event Type", selection: $eventType) {
ForEach(EventType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
}
Section("Optional") {
TextField("Distance (km)", text: $distanceKm)
.keyboardType(.decimalPad)
TextField("Max Participants", text: $maxParticipants)
.keyboardType(.numberPad)
}
}
.navigationTitle("Create Event")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Create") {
let request = CreateEventRequest(
title: title,
description: description,
eventType: eventType,
location: location,
latitude: 0,
longitude: 0,
startDate: Date(),
endDate: Date().addingTimeInterval(3600),
distanceKm: Double(distanceKm),
maxParticipants: Int(maxParticipants),
difficulty: nil
)
dismiss()
}
.disabled(title.isEmpty || location.isEmpty)
}
}
}
}
}
struct EventRowView: View {
let event: CommunityEvent
var body: some View {
HStack(spacing: 12) {
Image(systemName: event.eventType.icon)
.font(.system(size: 24))
.foregroundColor(event.eventType.color)
.frame(width: 44, height: 44)
.background(event.eventType.color.opacity(0.15))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 4) {
Text(event.title)
.font(.headline)
Text("\(event.location) \u2022 \(event.organizerName)")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 8) {
Text(formatDate(event.startDate))
.font(.caption)
.foregroundColor(.secondary)
if let spots = event.availableSpots, spots > 0 {
Text("\(spots) spots left")
.font(.caption2)
.foregroundColor(.green)
}
}
}
Spacer()
switch event.rsvpStatus {
case .going:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .maybe:
Image(systemName: "questionmark.circle.fill")
.foregroundColor(.orange)
case .notGoing:
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
case .pending:
Image(systemName: "circle")
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
}
#Preview {
CommunityEventsView()
}