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:
165
Lendair/Views/RaceDiscoveryView.swift
Normal file
165
Lendair/Views/RaceDiscoveryView.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user