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.
191 lines
7.1 KiB
Swift
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())
|
|
}
|
|
}
|