Files
wristcode/watch/WristCode/Views/Theme.swift
T
gib 7ca0362745 Watch: status, environment and notifications screens
A gear on the feed opens a status overview that leads with what is wrong:
offline hosts, hosts on a fallback route, tokens expiring within 7 days,
and push problems, with healthy hosts folded into one row. Each host opens
its route ladder with Reconnect. The notifications page shows registration,
the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN
fallback, and snapshot tests render the three screens.
2026-11-01 15:16:18 -05:00

191 lines
7.1 KiB
Swift

// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done,
// and red means broken.
import SwiftUI
enum Theme {
static let you = Color(red: 1.0, green: 0.690, blue: 0.180) // #ffb02e
static let work = Color(red: 0.310, green: 0.820, blue: 0.773) // #4fd1c5
static let done = Color(red: 0.420, green: 0.420, blue: 0.439) // #6b6b70
static let card = Color(red: 0.090, green: 0.090, blue: 0.102) // #17171a
static let control = Color(red: 0.173, green: 0.173, blue: 0.180) // #2c2c2e
static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e
}
extension Phase {
var color: Color {
if needsYou { return Theme.you }
if isWorking { return Theme.work }
return Theme.done
}
}
/// A dark rounded card with a 4 pt colored left edge.
struct Card<Content: View>: View {
var edge: Color
@ViewBuilder var content: Content
var body: some View {
VStack(alignment: .leading, spacing: 0) {
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 9)
.padding(.leading, 15)
.padding(.trailing, 11)
.background(alignment: .leading) {
ZStack(alignment: .leading) {
Theme.card
edge.frame(width: 4)
}
}
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
}
/// A full-width pill button, like mock B's Reply and Say the task.
struct PillButtonStyle: ButtonStyle {
var fill: Color
var foreground: Color = .black
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(foreground)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: Capsule())
}
}
/// A smaller button for choices on a card, such as Allow and Deny.
struct ChoiceButtonStyle: ButtonStyle {
var fill: Color = Theme.control
var foreground: Color = .white
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(foreground)
.lineLimit(2)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.padding(.horizontal, 4)
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
enum Format {
/// A short age for a card, such as "now", "4 min", "2 h" or "3 d".
static func age(of date: Date, now: Date = .now) -> String {
let minutes = Int(now.timeIntervalSince(date) / 60)
if minutes < 1 { return "now" }
if minutes < 60 { return "\(minutes) min" }
let hours = minutes / 60
if hours < 24 { return "\(hours) h" }
return "\(hours / 24) d"
}
/// How long until a date, such as "40 s", "3 min" or "2 h".
static func countdown(to date: Date, now: Date = .now) -> String {
let seconds = max(0, Int(date.timeIntervalSince(now).rounded()))
if seconds < 60 { return "\(seconds) s" }
if seconds < 3600 { return "\(seconds / 60) min" }
return "\(seconds / 3600) h"
}
/// "expires in 29 days", "expires today" or "expired 2 days ago", for a token.
static func expiry(_ date: Date, now: Date = .now) -> String {
let days = Int((date.timeIntervalSince(now) / 86_400).rounded(.down))
if date < now {
let ago = Int(now.timeIntervalSince(date) / 86_400)
return ago < 1 ? "expired today" : "expired \(ago) day\(ago == 1 ? "" : "s") ago"
}
if days < 1 { return "expires today" }
return "expires in \(days) day\(days == 1 ? "" : "s")"
}
/// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5".
static func modelName(_ id: String) -> String {
var words: [String] = []
var number: [String] = []
func flush() {
if !number.isEmpty { words.append(number.joined(separator: ".")) }
number = []
}
for part in id.split(whereSeparator: { $0 == "-" || $0 == "_" }).map(String.init) {
if part.allSatisfy(\.isNumber) {
number.append(part)
continue
}
flush()
switch part.lowercased() {
case "gpt": words.append("GPT")
default: words.append(part.prefix(1).uppercased() + part.dropFirst())
}
}
flush()
return words.joined(separator: " ")
}
}
/// Lays children out left to right and wraps them onto new lines, like the mock's reply chips.
struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews)
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: rows.last.map { $0.y + $0.height } ?? 0)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
for row in arrange(width: bounds.width, subviews: subviews) {
var x = bounds.minX
for index in row.indices {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(at: CGPoint(x: x, y: bounds.minY + row.y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
}
}
private struct Row {
var indices: [Int] = []
var y: CGFloat = 0
var width: CGFloat = 0
var height: CGFloat = 0
}
private func arrange(width: CGFloat, subviews: Subviews) -> [Row] {
var rows: [Row] = []
var row = Row()
for index in subviews.indices {
let size = subviews[index].sizeThatFits(.unspecified)
if !row.indices.isEmpty, row.width + spacing + size.width > width {
rows.append(row)
row = Row(y: row.y + row.height + spacing)
}
row.width += (row.indices.isEmpty ? 0 : spacing) + size.width
row.height = max(row.height, size.height)
row.indices.append(index)
}
if !row.indices.isEmpty { rows.append(row) }
return rows
}
}
/// A content-sized chip, for one-tap replies.
struct ChipButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Theme.card.opacity(configuration.isPressed ? 0.6 : 1), in: Capsule())
}
}