import SwiftUI private enum F1LiveLook { static let bg = Color(red: 0.043, green: 0.051, blue: 0.071) static let card = Color(red: 0.086, green: 0.102, blue: 0.137) static let stroke = Color.white.opacity(0.10) static let live = Color(red: 1.0, green: 0.231, blue: 0.188) static let gold = Color(red: 1.0, green: 0.82, blue: 0.32) } struct F1LiveScreen: View { enum Tab: String, CaseIterable, Identifiable { case overview, timing, map, telemetry, news var id: String { rawValue } var label: String { switch self { case .overview: return "Overzicht" case .timing: return "Live Timing" case .map: return "Track Map" case .telemetry: return "Telemetrie" case .news: return "Nieuws" } } } @EnvironmentObject private var app: AppModel @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @State private var tab: Tab = .overview @State private var payload: F1LiveDashboard? @State private var error: String? @State private var speedTrail: [Double] = [] private let clock = Timer.publish(every: 4, on: .main, in: .common).autoconnect() var body: some View { ZStack(alignment: .top) { F1LiveLook.bg.ignoresSafeArea() if let payload { loaded(payload) } else if let error { VStack(spacing: 14) { header(live: false, title: "F1 Live", subtitle: nil) ContentUnavailableView { Label("Live timing niet beschikbaar", systemImage: "flag.checkered") } description: { Text(error) } actions: { Button("Opnieuw") { Task { await load() } } .buttonStyle(McPrimaryButtonStyle()) } Spacer() } } else { VStack { header(live: false, title: "F1 Live", subtitle: nil) Spacer() ProgressView().controlSize(.large).tint(.white) Spacer() } } } .environment(\.colorScheme, .dark) .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) .task { await load() } .onReceive(clock) { _ in Task { await load(silent: true) } } } @ViewBuilder private func loaded(_ p: F1LiveDashboard) -> some View { VStack(spacing: 0) { header( live: p.live, title: p.session.meetingName ?? "F1 Live", subtitle: [ p.session.sessionName, p.session.circuitName, p.session.currentLap.map { "Ronde \($0)" }, ].compactMap { $0 }.joined(separator: " · ") ) tabBar ScrollView { Group { switch tab { case .overview: overview(p) case .timing: timingTable(p.timing) case .map: mapBlock(p) case .telemetry: telemetryBlock(p) case .news: newsList(p.news) } } .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 40) } } } private func header(live: Bool, title: String, subtitle: String?) -> some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { Button { dismiss() } label: { Image(systemName: "chevron.left") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) .frame(width: 36, height: 36) .background(.white.opacity(0.10), in: Circle()) } VStack(alignment: .leading, spacing: 2) { Text("F1 Live") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white.opacity(0.55)) Text(title) .font(.system(size: 20, weight: .bold)) .foregroundStyle(.white) .lineLimit(1) } Spacer(minLength: 8) if live { Text("LIVE") .font(.system(size: 11, weight: .heavy)) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 5) .background(F1LiveLook.live, in: Capsule()) } } HStack { Text("Aangedreven door OpenF1") .font(.system(size: 11, weight: .medium)) .foregroundStyle(.white.opacity(0.4)) Spacer() if let subtitle, !subtitle.isEmpty { Text(subtitle) .font(.system(size: 11, weight: .medium)) .foregroundStyle(.white.opacity(0.45)) .lineLimit(1) } } } .padding(.horizontal, 16) .padding(.top, 6) .padding(.bottom, 10) } private var tabBar: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Tab.allCases) { item in Button { tab = item } label: { Text(item.label) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(tab == item ? .white : .white.opacity(0.55)) .padding(.horizontal, 14) .frame(height: 34) .background(tab == item ? F1LiveLook.live : Color.white.opacity(0.08), in: Capsule()) } .buttonStyle(.plain) } } .padding(.horizontal, 16) } .padding(.bottom, 4) } private func overview(_ p: F1LiveDashboard) -> some View { VStack(alignment: .leading, spacing: 16) { if let w = p.weather { weatherCard(w, circuit: p.session.circuitName) } card { sectionTitle("Live posities") ForEach(Array(p.timing.prefix(8))) { d in timingRow(d, compact: true) } if p.timing.count > 8 { Button { tab = .timing } label: { Text("Bekijk alles") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white.opacity(0.7)) .frame(maxWidth: .infinity) .padding(.top, 6) } .buttonStyle(.plain) } } HStack(spacing: 10) { shortcut("Live Timing", "timer") { tab = .timing } shortcut("Track Map", "map") { tab = .map } shortcut("Telemetrie", "speedometer") { tab = .telemetry } } if let t = p.telemetry { telemetryCard(t) } if !p.news.isEmpty { sectionTitle("Nieuws") ForEach(p.news.prefix(4)) { item in newsCard(item) } } } } private func timingTable(_ rows: [F1LiveDriver]) -> some View { card { HStack { Text("POS").frame(width: 28, alignment: .leading) Text("Coureur").frame(maxWidth: .infinity, alignment: .leading) Text("Verschil").frame(width: 72, alignment: .trailing) Text("Lap").frame(width: 64, alignment: .trailing) Text("").frame(width: 22) } .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.white.opacity(0.4)) ForEach(rows) { d in timingRow(d, compact: false) } } } private func timingRow(_ d: F1LiveDriver, compact: Bool) -> some View { HStack(spacing: 8) { Text("\(d.position)") .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) .frame(width: 22, alignment: .leading) Circle() .fill(F1Hex.color(d.teamColor)) .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 1) { Text(d.acronym) .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) if !compact { Text(d.teamName) .font(.system(size: 11)) .foregroundStyle(.white.opacity(0.45)) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) Text(d.position == 1 ? "—" : d.gap) .font(.system(size: 12, weight: .semibold, design: .monospaced)) .foregroundStyle(d.position == 1 ? F1LiveLook.gold : .white.opacity(0.85)) if !compact { Text(d.lastLap) .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundStyle(.white.opacity(0.65)) .frame(width: 64, alignment: .trailing) } tyreBadge(d.tyre) } .padding(.vertical, 6) } private func mapBlock(_ p: F1LiveDashboard) -> some View { VStack(alignment: .leading, spacing: 14) { card { HStack { Text(p.map?.name ?? p.session.circuitName ?? "Circuit") .font(.system(size: 16, weight: .bold)) .foregroundStyle(.white) Spacer() Text("Van boven · live dots") .font(.system(size: 11, weight: .medium)) .foregroundStyle(.white.opacity(0.4)) } F1TrackMapCanvas(path: p.map?.path ?? [], cars: p.map?.cars ?? []) .frame(height: 280) .padding(.top, 8) } if let leader = p.timing.first { driverSpotlight(leader) } if let t = p.telemetry { telemetryCard(t) } } } private func telemetryBlock(_ p: F1LiveDashboard) -> some View { VStack(alignment: .leading, spacing: 14) { if let t = p.telemetry { telemetryCard(t) if speedTrail.count > 1 { card { sectionTitle("Snelheid") F1Sparkline(values: speedTrail) .stroke(F1LiveLook.live, lineWidth: 2) .frame(height: 120) } } } else { Text("Geen telemetrie voor deze sessie.") .font(.system(size: 14)) .foregroundStyle(.white.opacity(0.5)) } if !p.raceControl.isEmpty { card { sectionTitle("Race control") ForEach(p.raceControl.prefix(8)) { item in HStack(alignment: .top, spacing: 10) { Circle() .fill(flagColor(item.flag)) .frame(width: 8, height: 8) .padding(.top, 5) VStack(alignment: .leading, spacing: 2) { Text(item.message) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) if let date = item.date { Text(F1LiveFormat.clock(date)) .font(.system(size: 11)) .foregroundStyle(.white.opacity(0.4)) } } } .padding(.vertical, 4) } } } } } private func newsList(_ items: [F1NewsItem]) -> some View { VStack(alignment: .leading, spacing: 12) { Text("RacingNews365") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white.opacity(0.45)) ForEach(items) { item in newsCard(item) } } } private func weatherCard(_ w: F1LiveWeather, circuit: String?) -> some View { card { HStack { VStack(alignment: .leading, spacing: 4) { Text(circuit ?? "Weer") .font(.system(size: 15, weight: .bold)) .foregroundStyle(.white) Text(w.rainfall == true ? "Regen" : "Droog") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white.opacity(0.5)) } Spacer() weatherStat("Lucht", w.airTemperature.map { "\(Int($0.rounded()))°" } ?? "—") weatherStat("Baan", w.trackTemperature.map { "\(Int($0.rounded()))°" } ?? "—") weatherStat("Vocht", w.humidity.map { "\(Int($0.rounded()))%" } ?? "—") } } } private func weatherStat(_ label: String, _ value: String) -> some View { VStack(spacing: 2) { Text(value) .font(.system(size: 16, weight: .bold, design: .rounded)) .foregroundStyle(.white) Text(label) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.white.opacity(0.4)) } .frame(width: 54) } private func telemetryCard(_ t: F1LiveTelemetry) -> some View { card { HStack { sectionTitle("Live telemetrie") Spacer() Text(t.acronym) .font(.system(size: 13, weight: .bold)) .foregroundStyle(.white.opacity(0.6)) } HStack(alignment: .bottom, spacing: 16) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text("\(Int(t.speed ?? 0))") .font(.system(size: 44, weight: .bold, design: .rounded)) .foregroundStyle(.white) Text("km/h") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white.opacity(0.45)) } Spacer() Text("\(Int(t.gear ?? 0))") .font(.system(size: 36, weight: .bold, design: .rounded)) .foregroundStyle(F1LiveLook.gold) } meter("Gas", (t.throttle ?? 0) / 100, .green) meter("Rem", (t.brake ?? 0) / 100, F1LiveLook.live) HStack { Text("RPM \(t.rpm.map { "\(Int($0))" } ?? "—")") Spacer() if t.drs == true { Text("DRS") .font(.system(size: 11, weight: .heavy)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.green.opacity(0.25), in: Capsule()) } } .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white.opacity(0.55)) } } private func meter(_ label: String, _ value: Double, _ color: Color) -> some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(label) Spacer() Text("\(Int((value * 100).clamped(to: 0...100)))%") } .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white.opacity(0.5)) GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(Color.white.opacity(0.08)) Capsule() .fill(color) .frame(width: max(4, geo.size.width * value.clamped(to: 0...1))) } } .frame(height: 8) } } private func driverSpotlight(_ d: F1LiveDriver) -> some View { card { HStack(spacing: 12) { ZStack { Circle().fill(F1Hex.color(d.teamColor).opacity(0.25)) if let url = d.headshotUrl { RemoteImage(url: url, contentMode: .fill) .clipShape(Circle()) } } .frame(width: 64, height: 64) VStack(alignment: .leading, spacing: 3) { Text(d.fullName) .font(.system(size: 16, weight: .bold)) .foregroundStyle(.white) Text(d.teamName) .font(.system(size: 12)) .foregroundStyle(.white.opacity(0.5)) } Spacer() Text("P\(d.position)") .font(.system(size: 22, weight: .black, design: .rounded)) .foregroundStyle(.white) } HStack { Text("Laatste \(d.lastLap)") Spacer() Text("Beste \(d.bestLap)") } .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.white.opacity(0.55)) .padding(.top, 6) } } private func newsCard(_ item: F1NewsItem) -> some View { Button { if let url = URL(string: item.url) { openURL(url) } } label: { HStack(alignment: .top, spacing: 12) { RemoteImage(url: item.imageUrl, contentMode: .fill) .frame(width: 92, height: 64) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) VStack(alignment: .leading, spacing: 4) { Text(item.title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white) .multilineTextAlignment(.leading) .lineLimit(3) Text(F1LiveFormat.newsDate(item.publishedAt) + " · RacingNews365") .font(.system(size: 11)) .foregroundStyle(.white.opacity(0.4)) } Spacer(minLength: 0) } .padding(10) .background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(F1LiveLook.stroke, lineWidth: 1) ) } .buttonStyle(.plain) } private func shortcut(_ title: String, _ icon: String, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(spacing: 8) { Image(systemName: icon) .font(.system(size: 18, weight: .semibold)) Text(title) .font(.system(size: 11, weight: .semibold)) .lineLimit(1) .minimumScaleFactor(0.8) } .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(F1LiveLook.stroke, lineWidth: 1) ) } .buttonStyle(.plain) } private func card(@ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { content() } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(F1LiveLook.stroke, lineWidth: 1) ) } private func sectionTitle(_ t: String) -> some View { Text(t) .font(.system(size: 15, weight: .bold)) .foregroundStyle(.white) } private func tyreBadge(_ tyre: String) -> some View { Text(tyre) .font(.system(size: 10, weight: .heavy)) .foregroundStyle(tyre == "H" ? .black : .white) .frame(width: 22, height: 22) .background(tyreColor(tyre), in: Circle()) } private func tyreColor(_ tyre: String) -> Color { switch tyre.uppercased() { case "S", "SS", "US": return .red case "M": return .yellow case "H": return .white case "I": return .green case "W": return .blue default: return .gray } } private func flagColor(_ flag: String?) -> Color { switch (flag ?? "").uppercased() { case "YELLOW", "DOUBLE YELLOW": return .yellow case "GREEN": return .green case "RED": return .red case "BLUE": return .blue case "BLACK AND WHITE": return .white default: return F1LiveLook.live } } private func load(silent: Bool = false) async { do { let next = try await app.api.f1Live() payload = next error = nil if let speed = next.telemetry?.speed { speedTrail.append(speed) if speedTrail.count > 40 { speedTrail.removeFirst(speedTrail.count - 40) } } } catch { if payload == nil && !silent { self.error = error.localizedDescription } } } } private struct F1TrackMapCanvas: View { let path: [F1LivePoint] let cars: [F1LiveCar] var body: some View { GeometryReader { geo in let inset: CGFloat = 28 let w = max(geo.size.width - inset * 2, 1) let h = max(geo.size.height - inset * 2, 1) ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.black.opacity(0.35)) if path.count > 1 { Path { p in p.move(to: point(path[0], w: w, h: h, inset: inset)) for pt in path.dropFirst() { p.addLine(to: point(pt, w: w, h: h, inset: inset)) } } .stroke(Color.white.opacity(0.82), style: StrokeStyle(lineWidth: 5, lineJoin: .round, lineCap: .round)) } else { Text("Geen circuitkaart") .font(.system(size: 13)) .foregroundStyle(.white.opacity(0.4)) .frame(maxWidth: .infinity, maxHeight: .infinity) } ForEach(cars, id: \.driverNumber) { car in let c = point(F1LivePoint(x: car.x, y: car.y), w: w, h: h, inset: inset) Text(car.acronym) .font(.system(size: 8, weight: .heavy)) .foregroundStyle(.white) .padding(.horizontal, 4) .padding(.vertical, 2) .background(F1Hex.color(car.teamColor), in: Capsule()) .position(c) } } } } private func point(_ p: F1LivePoint, w: CGFloat, h: CGFloat, inset: CGFloat) -> CGPoint { CGPoint(x: inset + CGFloat(p.x) * w, y: inset + CGFloat(p.y) * h) } } private struct F1Sparkline: Shape { let values: [Double] func path(in rect: CGRect) -> Path { guard let minV = values.min(), let maxV = values.max(), values.count > 1 else { return Path() } let span = max(maxV - minV, 1) var p = Path() for (i, v) in values.enumerated() { let x = rect.width * CGFloat(i) / CGFloat(values.count - 1) let y = rect.height - CGFloat((v - minV) / span) * rect.height if i == 0 { p.move(to: CGPoint(x: x, y: y)) } else { p.addLine(to: CGPoint(x: x, y: y)) } } return p } } private enum F1Hex { static func color(_ hex: String) -> Color { var h = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() } guard h.count == 6, let n = UInt64(h, radix: 16) else { return .gray } return Color( .sRGB, red: Double((n >> 16) & 0xFF) / 255, green: Double((n >> 8) & 0xFF) / 255, blue: Double(n & 0xFF) / 255, opacity: 1 ) } } private enum F1LiveFormat { static func clock(_ iso: String) -> String { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let d = f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) guard let d else { return iso } let out = DateFormatter() out.locale = Locale(identifier: "nl_NL") out.dateFormat = "HH:mm:ss" return out.string(from: d) } static func newsDate(_ iso: String?) -> String { guard let iso, !iso.isEmpty else { return "" } let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let d = f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) guard let d else { return iso } let out = DateFormatter() out.locale = Locale(identifier: "nl_NL") out.dateFormat = "d MMM HH:mm" return out.string(from: d) } } private extension Comparable { func clamped(to range: ClosedRange) -> Self { min(max(self, range.lowerBound), range.upperBound) } }