Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://simon-says-duo/MyApp/RetroTheme.swift

MyApp/RetroTheme.swift

11 KB · 231 lines · swift · Download from the repository page

MyApp/RetroTheme.swift
import SwiftUI enum RetroTheme {    static let canvas = Color(red: 243/255, green: 240/255, blue: 231/255)    static let surface = Color(red: 1, green: 253/255, blue: 245/255)    static let ink = Color(red: 20/255, green: 20/255, blue: 20/255)    static let yellow = Color(red: 1, green: 229/255, blue: 0)    static let green = Color(red: 183/255, green: 1, blue: 0)    static let pink = Color(red: 1, green: 46/255, blue: 155/255)    static let blue = Color(red: 37/255, green: 75/255, blue: 1)    static let error = Color(red: 194/255, green: 31/255, blue: 31/255)    static let border: CGFloat = 2    static let shadow: CGFloat = 4    static let radius: CGFloat = 4     // MARK: Spacing scale — every screen uses these instead of ad-hoc numbers,    // so margins and gaps read as one consistent system rather than per-view guesses.    static let spaceXS: CGFloat = 6    static let spaceS: CGFloat = 12    static let spaceM: CGFloat = 18    static let spaceL: CGFloat = 24    static let spaceXL: CGFloat = 32     /// Content never grows wider than this, so a page of controls stays a    /// readable column instead of stretching edge-to-edge on an iPad.    static let contentMaxWidth: CGFloat = 760    static let screenPaddingH: CGFloat = RetroTheme.spaceL    static let screenPaddingTop: CGFloat = RetroTheme.spaceL    static let screenPaddingBottom: CGFloat = RetroTheme.spaceXL     static func heading(_ style: Font.TextStyle = .title) -> Font {        .system(style, design: .default).weight(.black).width(.condensed)    }    static func mono(_ style: Font.TextStyle = .caption) -> Font {        .system(style, design: .monospaced).weight(.bold)    }} /// The scrollable shell every top-level screen uses: a centered column capped/// at `contentMaxWidth` (so wide iPad windows don't leave content stranded/// against one edge with a dead zone on the other), consistent outer margins/// on every side, and the canvas texture behind it.struct RetroScreen<Content: View>: View {    var spacing: CGFloat = RetroTheme.spaceL    var alignment: HorizontalAlignment = .leading    @ViewBuilder var content: Content    var body: some View {        ScrollView {            VStack(alignment: alignment, spacing: spacing) {                content            }            .padding(.horizontal, RetroTheme.screenPaddingH)            .padding(.top, RetroTheme.screenPaddingTop)            .padding(.bottom, RetroTheme.screenPaddingBottom)            .frame(maxWidth: RetroTheme.contentMaxWidth)            .frame(maxWidth: .infinity) // re-centers the capped column; without this a ScrollView leaves it pinned to the leading edge        }        .background { RetroGridBackground().ignoresSafeArea() }        .scrollDismissesKeyboard(.interactively)    }} /// A fixed action bar pinned to the bottom of a screen, styled the same way/// everywhere it appears (ink top rule, canvas fill, centered capped column)/// instead of each screen hand-rolling its own padding and background.struct RetroBottomBar<Content: View>: View {    @ViewBuilder var content: Content    var body: some View {        VStack(spacing: RetroTheme.spaceS) { content }            .frame(maxWidth: RetroTheme.contentMaxWidth)            .frame(maxWidth: .infinity)            .padding(.horizontal, RetroTheme.screenPaddingH)            .padding(.vertical, RetroTheme.spaceM)            .background(RetroTheme.canvas)            .overlay(alignment: .top) { Rectangle().fill(RetroTheme.ink).frame(height: RetroTheme.border) }    }} /// Faint graph-paper grid behind every screen — a quiet nod to schematics and/// terminal displays. Kept to ~3% opacity so it never competes with content.struct RetroGridBackground: View {    var body: some View {        RetroTheme.canvas.overlay {            Canvas { context, size in                let step: CGFloat = 32                var path = Path()                var x: CGFloat = 0                while x <= size.width { path.move(to: CGPoint(x: x, y: 0)); path.addLine(to: CGPoint(x: x, y: size.height)); x += step }                var y: CGFloat = 0                while y <= size.height { path.move(to: CGPoint(x: 0, y: y)); path.addLine(to: CGPoint(x: size.width, y: y)); y += step }                context.stroke(path, with: .color(RetroTheme.ink.opacity(0.035)), lineWidth: 1)            }        }        .allowsHitTesting(false)    }} struct RetroPanel<Content: View>: View {    var color: Color = RetroTheme.surface    @ViewBuilder var content: Content    var body: some View {        content.padding(RetroTheme.spaceM).frame(maxWidth: .infinity, alignment: .leading)            .background {                RoundedRectangle(cornerRadius: RetroTheme.radius).fill(color)                    .shadow(color: RetroTheme.ink, radius: 0, x: RetroTheme.shadow, y: RetroTheme.shadow)            }            .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, lineWidth: RetroTheme.border))            .foregroundStyle(RetroTheme.ink)    }} struct RetroButtonStyle: ButtonStyle {    var color: Color = RetroTheme.green    @Environment(\.isEnabled) private var enabled    func makeBody(configuration: Configuration) -> some View {        configuration.label.font(RetroTheme.heading(.headline))            .padding(.horizontal, RetroTheme.spaceM).padding(.vertical, RetroTheme.spaceS).frame(minWidth: 44, minHeight: 44)            .foregroundStyle(RetroTheme.ink)            .background {                RoundedRectangle(cornerRadius: RetroTheme.radius).fill(enabled ? color : RetroTheme.canvas)                    .shadow(color: RetroTheme.ink, radius: 0, x: configuration.isPressed ? 1 : RetroTheme.shadow, y: configuration.isPressed ? 1 : RetroTheme.shadow)            }            .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, style: StrokeStyle(lineWidth: RetroTheme.border, dash: enabled ? [] : [4, 2])))            .offset(x: configuration.isPressed ? 3 : 0, y: configuration.isPressed ? 3 : 0)            .animation(.easeOut(duration: 0.08), value: configuration.isPressed)    }} struct RetroTag: View {    let title: String    var color: Color = RetroTheme.blue    var body: some View {        Text(title).font(RetroTheme.mono(.caption)).tracking(0.5)            .padding(.horizontal, RetroTheme.spaceS).padding(.vertical, RetroTheme.spaceXS)            .foregroundStyle(color == RetroTheme.blue || color == RetroTheme.error ? Color.white : RetroTheme.ink).background(color)            .accessibilityLabel(title)    }} /// A section title with a blinking terminal-style cursor — the one deliberate/// bit of motion in the theme, and it respects Reduce Motion.struct RetroSectionHeader: View {    let title: String    @Environment(\.accessibilityReduceMotion) private var reduceMotion    @State private var cursorOn = true    var body: some View {        HStack(spacing: RetroTheme.spaceXS) {            // Left as a plain, unwrapped Text (not combined into a custom            // accessibility element) so it stays a normal staticText, exactly            // as UI tests locate it by its visible, uppercased label.            Text(title.uppercased()).font(RetroTheme.heading(.title2))            if !reduceMotion {                Text("▮").font(RetroTheme.heading(.title2)).opacity(cursorOn ? 1 : 0)                    .accessibilityHidden(true)                    .onAppear {                        withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) { cursorOn.toggle() }                    }            }        }        .frame(maxWidth: .infinity, alignment: .leading)        .padding(.horizontal, RetroTheme.spaceM).padding(.vertical, RetroTheme.spaceS + 2)        .foregroundStyle(RetroTheme.ink).background(RetroTheme.yellow)        .overlay(Rectangle().stroke(RetroTheme.ink, lineWidth: RetroTheme.border))    }} /// Small uppercase monospaced readout, used for the handful of places (nav/// titles, dashboard labels) that want the "terminal display" voice without/// the full yellow banner treatment of `RetroSectionHeader`.struct RetroTitleLabel: View {    let title: String    var body: some View {        Text(title.uppercased()).font(RetroTheme.heading(.title3))    }} struct RetroStepIndicator: View {    let step: Int    var body: some View {        HStack(spacing: RetroTheme.spaceS) {            ForEach(1...3, id: \.self) { number in                Text("\(number) " + ["CONSENT", "SETUP", "PLAY"][number - 1])                    .font(RetroTheme.mono(.caption)).padding(RetroTheme.spaceS)                    .frame(maxWidth: .infinity)                    .background(step == number ? RetroTheme.yellow : RetroTheme.surface)                    .overlay(Rectangle().stroke(RetroTheme.ink, lineWidth: step == number ? 3 : 1))                    .accessibilityLabel("Step \(number), " + ["consent", "setup", "game"][number - 1] + (step == number ? ", current" : ""))            }        }    }} /// Consistent empty-state styling (used wherever `ContentUnavailableView`/// would otherwise render in the system's default gray, which clashes with/// the high-contrast retro palette).struct RetroEmptyState: View {    let title: String    let systemImage: String    let message: String    var body: some View {        VStack(spacing: RetroTheme.spaceS) {            Image(systemName: systemImage).font(.system(size: 40)).foregroundStyle(RetroTheme.ink.opacity(0.35))            Text(title).font(RetroTheme.heading(.title3))            Text(message).font(.subheadline).foregroundStyle(RetroTheme.ink.opacity(0.65)).multilineTextAlignment(.center)        }        .frame(maxWidth: .infinity)        .padding(RetroTheme.spaceXL)        .accessibilityElement(children: .combine)    }} struct RetroAppearance: ViewModifier {    func body(content: Content) -> some View {        content.foregroundStyle(RetroTheme.ink).tint(RetroTheme.blue)            .background { RetroGridBackground().ignoresSafeArea() }            .preferredColorScheme(.light)            .buttonStyle(RetroButtonStyle())    }}extension View {    func retroAppearance() -> some View { modifier(RetroAppearance()) }     /// Styles the navigation bar's title with the retro display font while    /// keeping the plain string as the real `navigationTitle` (back-button    /// label, accessibility, Spotlight) — one call site for shared chrome.    func retroTitle(_ title: String) -> some View {        self.navigationTitle(title).navigationBarTitleDisplayMode(.inline)            .toolbar { ToolbarItem(placement: .principal) { RetroTitleLabel(title: title) } }    }}