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:
182
Lendair/Views/RaceDetailView.swift
Normal file
182
Lendair/Views/RaceDetailView.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RaceDetailView: View {
|
||||
let race: Race
|
||||
@StateObject private var viewModel = RaceDiscoveryViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
raceHeader
|
||||
raceInfoSection
|
||||
raceDescription
|
||||
actionButtons
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.navigationTitle(race.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var raceHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: race.raceType.icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.orange)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(race.raceType.displayName)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
Text("\(race.distanceKm) km \u2022 \(race.terrainType.displayName)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.toggleSaveRace(id: race.id)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: race.isSaved ? "bookmark.fill" : "bookmark")
|
||||
.font(.title3)
|
||||
.foregroundColor(race.isSaved ? .blue : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
infoItem(label: "Date", value: formatDate(race.raceDate))
|
||||
infoItem(label: "Location", value: race.location)
|
||||
infoItem(label: "Days Left", value: "\(race.daysUntilRace)")
|
||||
}
|
||||
|
||||
if let count = race.participantCount {
|
||||
HStack(spacing: 20) {
|
||||
infoItem(label: "Participants", value: "\(count)")
|
||||
infoItem(label: "Elevation", value: "\(Int(race.elevationGain))m")
|
||||
infoItem(label: "Terrain", value: race.terrainType.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private func infoItem(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var raceInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Race Details")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
detailRow(label: "Organizer", value: race.organizerName)
|
||||
detailRow(label: "Distance", value: "\(race.distanceKm) km")
|
||||
detailRow(label: "Elevation Gain", value: "\(Int(race.elevationGain))m")
|
||||
detailRow(label: "Terrain", value: race.terrainType.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var raceDescription: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("About This Race")
|
||||
.font(.headline)
|
||||
Text(race.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var actionButtons: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let url = race.registrationUrl, !race.isRegistered {
|
||||
Button {
|
||||
if let registrationUrl = URL(string: url) {
|
||||
UIApplication.shared.open(registrationUrl)
|
||||
}
|
||||
} label: {
|
||||
Text("Register Now")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
if race.isRegistered {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
Text("Registered")
|
||||
}
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.green.opacity(0.15))
|
||||
.foregroundColor(.green)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
RaceDetailView(race: sampleRace)
|
||||
}
|
||||
}
|
||||
|
||||
private var sampleRace: Race {
|
||||
Race(
|
||||
id: "1",
|
||||
name: "City Marathon 2026",
|
||||
description: "An annual marathon through the heart of the city. Features a flat, fast course suitable for all levels.",
|
||||
location: "Downtown",
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
raceDate: Date().addingTimeInterval(90 * 24 * 3600),
|
||||
distanceKm: 42.2,
|
||||
raceType: .road,
|
||||
organizerName: "City Athletics Club",
|
||||
registrationUrl: "https://example.com/register",
|
||||
imageUrl: nil,
|
||||
participantCount: 5000,
|
||||
isRegistered: false,
|
||||
isSaved: true,
|
||||
elevationGain: 120,
|
||||
terrainType: .flat
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user