remindwall / RWPureSwift / Sources / Dashboard / Widgets / RailView.swift
Back to remindwall — Current RemindWall
import SwiftUI
/// SPEC-DASHBOARD wall/pad type scale. The Mac target drives the 32" LG wall
/// monitor, the iOS target the arm's-length iPads - the platform split IS the
/// viewing-distance split.
enum RailScale {
#if targetEnvironment(macCatalyst)
static let primary: CGFloat = 48
static let secondary: CGFloat = 32
static let chip: CGFloat = 24
#else
static let primary: CGFloat = 28
static let secondary: CGFloat = 20
static let chip: CGFloat = 14
#endif
}
/// Candidate A's vertical rail: most urgent card at the BOTTOM (eye level on
/// the portrait panels), overflow chip above the stack. Renders whatever
/// DashboardRail.assemble produced - no ordering decisions live here, only
/// stacking direction.
public struct RailView: View {
let rail: DashboardRail
public init(rail: DashboardRail) {
self.rail = rail
}
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
if rail.overflowCount > 0 {
Text("+\(rail.overflowCount) more")
.font(.system(size: RailScale.chip, weight: .semibold))
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color.black.opacity(0.5))
.foregroundStyle(.white)
.clipShape(Capsule())
.frame(maxWidth: .infinity, alignment: .trailing)
.accessibilityIdentifier("RailOverflow")
}
ForEach(rail.visible.reversed()) { card in
RailCardView(card: card)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.default, value: rail)
}
}
struct RailCardView: View {
let card: RailCard
var body: some View {
switch card.content {
case let .transit(route, label, etaText, isLate, isLive):
cardRow {
routeChip(route, background: Color(red: 0.18, green: 0.44, blue: 0.70))
titleText(label)
Spacer(minLength: 8)
etaText_(etaText, dimmed: !isLive)
if isLate { lateCapsule() }
}
case let .drive(label, etaText, isLate):
cardRow {
routeChip("🚗", background: Color(red: 0.30, green: 0.48, blue: 0.25))
titleText(label)
Spacer(minLength: 8)
etaText_(etaText, dimmed: false)
if isLate { lateCapsule() }
}
case let .upNext(title, timeUntil, emoji):
cardRow {
if let emoji {
Text(emoji).font(.system(size: RailScale.primary))
}
titleText(title)
Spacer(minLength: 8)
Text(timeUntil)
.font(.system(size: RailScale.secondary, weight: .semibold))
.opacity(0.9)
}
case let .errorChip(message):
cardRow {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: RailScale.chip))
.foregroundStyle(.yellow)
Text(message)
.font(.system(size: RailScale.chip))
.lineLimit(1)
Spacer(minLength: 0)
}
}
}
@ViewBuilder
private func cardRow(@ViewBuilder content: () -> some View) -> some View {
HStack(spacing: 12) {
content()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black.opacity(0.68))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
private func routeChip(_ text: String, background: Color) -> some View {
Text(text)
.font(.system(size: RailScale.primary, weight: .bold))
.padding(.horizontal, 12)
.padding(.vertical, 2)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func titleText(_ text: String) -> some View {
Text(text)
.font(.system(size: RailScale.secondary, weight: .semibold))
.lineLimit(1)
}
private func etaText_(_ text: String, dimmed: Bool) -> some View {
Text(text)
.font(.system(size: RailScale.primary, weight: .bold))
.italic(dimmed)
.opacity(dimmed ? 0.75 : 1.0)
.lineLimit(1)
}
private func lateCapsule() -> some View {
Text("LATE")
.font(.system(size: RailScale.chip, weight: .bold))
.padding(.horizontal, 10)
.padding(.vertical, 2)
.background(Color(red: 0.76, green: 0.18, blue: 0.12))
.clipShape(Capsule())
}
}
/// Tier 2 chips for the upper ambient zone (battery lands here in H1.4).
public struct AmbientChipsView: View {
let chips: [AmbientChip]
public init(chips: [AmbientChip]) {
self.chips = chips
}
public var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(chips) { chip in
HStack(spacing: 6) {
Image(systemName: chip.systemImage)
Text(chip.text)
}
.font(.system(size: RailScale.chip, weight: .medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.black.opacity(0.55))
.foregroundStyle(Color(red: 1.0, green: 0.82, blue: 0.54))
.clipShape(Capsule())
}
}
}
}
#Preview("full rail") {
ZStack {
Color.gray
VStack {
Spacer()
RailView(rail: DashboardRail.assemble([
RailCard(
id: "bus-12", priority: .lateBus, etaSeconds: 240,
content: .transit(route: "12", label: "School bus", etaText: "4 min", isLate: true, isLive: true)
),
RailCard(
id: "drive-school", priority: .slowRoute, etaSeconds: 1080,
content: .drive(label: "School run", etaText: "18 min", isLate: true)
),
RailCard(
id: "bus-550", priority: .onTimeBus, etaSeconds: 840,
content: .transit(route: "550", label: "Commute", etaText: "14 min", isLate: false, isLive: false)
),
RailCard(
id: "upnext", priority: .upNext, etaSeconds: 1500,
content: .upNext(title: "Dentist", timeUntil: "in 25 min", emoji: "🦷")
),
RailCard(
id: "error-transit", priority: .errorChip,
content: .errorChip(message: "Cannot reach transit API")
),
]))
.padding()
}
}
}