Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / Dashboard / Widgets / RailView.swift

Back to remindwall — Current RemindWall

Download this file

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()
        }
    }
}