- 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>
166 lines
5.0 KiB
Swift
166 lines
5.0 KiB
Swift
import SwiftUI
|
|
|
|
struct RaceDiscoveryView: View {
|
|
@StateObject private var viewModel = RaceDiscoveryViewModel()
|
|
@State private var showingFilters = false
|
|
@State private var showingSavedRaces = false
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
Group {
|
|
if viewModel.isLoading && viewModel.races.isEmpty {
|
|
loadingView
|
|
} else if viewModel.races.isEmpty {
|
|
emptyStateView
|
|
} else {
|
|
raceListView
|
|
}
|
|
}
|
|
.navigationTitle("Race Discovery")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Menu {
|
|
Button {
|
|
showingSavedRaces.toggle()
|
|
} label: {
|
|
Text("Saved Races")
|
|
Image(systemName: "bookmark.fill")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingSavedRaces) {
|
|
NavigationView {
|
|
SavedRacesSheet(viewModel: viewModel)
|
|
.navigationTitle("Saved Races")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
Task {
|
|
await viewModel.fetchRaces()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var raceListView: some View {
|
|
List {
|
|
Section("Upcoming Races") {
|
|
ForEach(viewModel.upcomingRaces) { race in
|
|
NavigationLink(destination: RaceDetailView(race: race)) {
|
|
RaceRowView(race: race)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.refreshable {
|
|
await viewModel.fetchRaces()
|
|
}
|
|
}
|
|
|
|
private var loadingView: some View {
|
|
VStack(spacing: 16) {
|
|
ProgressView()
|
|
Text("Loading Races...")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.vertical, 60)
|
|
}
|
|
|
|
private var emptyStateView: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "flag.fill")
|
|
.font(.system(size: 64))
|
|
.foregroundColor(.secondary)
|
|
Text("No Races Found")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
Text("Discover local races and events to train for.")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
}
|
|
.padding(.vertical, 60)
|
|
}
|
|
}
|
|
|
|
struct SavedRacesSheet: View {
|
|
@ObservedObject var viewModel: RaceDiscoveryViewModel
|
|
|
|
var body: some View {
|
|
List {
|
|
if viewModel.savedRaces.isEmpty {
|
|
Text("No saved races yet")
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
ForEach(viewModel.savedRaces) { race in
|
|
RaceRowView(race: race)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RaceRowView: View {
|
|
let race: Race
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: race.raceType.icon)
|
|
.font(.system(size: 24))
|
|
.foregroundColor(.orange)
|
|
.frame(width: 44, height: 44)
|
|
.background(Color.orange.opacity(0.15))
|
|
.cornerRadius(10)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(race.name)
|
|
.font(.headline)
|
|
Text("\(race.location) \u2022 \(race.distanceKm) km")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
HStack(spacing: 8) {
|
|
Text(formatDate(race.raceDate))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
if race.isRegistered {
|
|
Text("Registered")
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.green.opacity(0.2))
|
|
.cornerRadius(4)
|
|
.foregroundColor(.green)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if race.isSaved {
|
|
Image(systemName: "bookmark.fill")
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func formatDate(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .short
|
|
formatter.timeStyle = .none
|
|
return formatter.string(from: date)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
RaceDiscoveryView()
|
|
}
|