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>
This commit is contained in:
236
Lendair/Views/CommunityEventsView.swift
Normal file
236
Lendair/Views/CommunityEventsView.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user