stremio/apps/ios/VMC/UI/F1LiveScreen.swift

849 lines
32 KiB
Swift

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
@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())
}
}
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) {
if items.isEmpty {
Text("Nog geen nieuws.")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5))
} else {
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 {
NavigationLink {
F1NewsArticleScreen(preview: item)
} label: {
HStack(alignment: .top, spacing: 12) {
RemoteImage(url: item.imageUrl, contentMode: .fill)
.frame(width: 104, height: 74)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 6) {
Text(item.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.leading)
.lineLimit(3)
newsDateLabel(item.publishedAt)
}
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)
}
@ViewBuilder
private func newsDateLabel(_ iso: String?) -> some View {
let date = F1LiveFormat.newsDate(iso)
if !date.isEmpty {
Text(date)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.4))
}
}
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<Content: View>(@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, lineCap: .round, lineJoin: .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>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
private struct F1NewsArticleScreen: View {
let preview: F1NewsItem
@EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss
@State private var article: F1NewsArticle?
@State private var error: String?
var body: some View {
ZStack(alignment: .topLeading) {
F1LiveLook.bg.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
hero
VStack(alignment: .leading, spacing: 18) {
Text(article?.title ?? preview.title)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true)
metaRow
if let dek = article?.dek, !dek.isEmpty {
Text(dek)
.font(.system(size: 17, weight: .regular))
.foregroundStyle(.white.opacity(0.72))
.lineSpacing(5)
}
if let error {
Text(error)
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.55))
} else if article == nil {
ProgressView()
.tint(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
} else {
articleBody
}
}
.padding(.horizontal, 20)
.padding(.top, 22)
.padding(.bottom, 56)
}
}
backButton
}
.environment(\.colorScheme, .dark)
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.task { await load() }
}
private var hero: some View {
ZStack(alignment: .bottom) {
RemoteImage(url: article?.imageUrl ?? preview.imageUrl, contentMode: .fill)
.frame(height: 280)
.frame(maxWidth: .infinity)
.clipped()
LinearGradient(
colors: [.clear, F1LiveLook.bg.opacity(0.55), F1LiveLook.bg],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 140)
}
.frame(height: 280)
.clipped()
}
private var metaRow: some View {
let date = F1LiveFormat.newsDate(article?.publishedAt ?? preview.publishedAt)
return Group {
if !date.isEmpty {
Text(date.uppercased())
.font(.system(size: 11, weight: .semibold))
.tracking(0.8)
.foregroundStyle(.white.opacity(0.42))
}
}
}
@ViewBuilder
private var articleBody: some View {
let blocks = article?.blocks ?? []
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
switch block.type {
case "h":
Text(block.text ?? "")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(.white)
.padding(.top, 8)
case "quote":
VStack(alignment: .leading, spacing: 8) {
Text(block.text ?? "")
.font(.system(size: 18, weight: .medium).italic())
.foregroundStyle(.white.opacity(0.92))
.lineSpacing(4)
if let who = block.attribution, !who.isEmpty {
Text(who)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.45))
}
}
.padding(.leading, 14)
.overlay(alignment: .leading) {
Capsule()
.fill(F1LiveLook.live)
.frame(width: 3)
}
.padding(.vertical, 6)
case "image":
RemoteImage(url: block.imageUrl, contentMode: .fill)
.frame(maxWidth: .infinity)
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
default:
Text(block.text ?? "")
.font(.system(size: 17))
.foregroundStyle(.white.opacity(0.88))
.lineSpacing(6)
}
}
}
private var backButton: some View {
Button { dismiss() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.black.opacity(0.45), in: Circle())
.overlay(Circle().strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
}
.padding(.leading, 16)
.padding(.top, 6)
}
private func load() async {
do {
article = try await app.api.f1NewsArticle(id: preview.id)
error = nil
} catch {
self.error = "Artikel kon niet worden geladen."
}
}
}