Files
wristcode/watch/WristCode/Views/Theme.swift
T

237 lines
8.6 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 card holding one switch, like the mocks' setting rows. `tint` fills the switch when it is on.
/// The whole card toggles it.
struct SwitchCard: View {
var title: String
@Binding var isOn: Bool
var tint: Color = Theme.work
var edge: Color = Theme.card
var body: some View {
Toggle(isOn: $isOn) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
}
.toggleStyle(CardSwitchStyle(tint: tint, edge: edge))
}
}
/// The mocks' switch drawn by hand: watchOS's own renders as a placeholder in snapshots.
private struct CardSwitchStyle: ToggleStyle {
var tint: Color
var edge: Color
func makeBody(configuration: Configuration) -> some View {
Button { configuration.isOn.toggle() } label: {
Card(edge: edge) {
HStack {
configuration.label
Spacer(minLength: 4)
Capsule()
.fill(configuration.isOn ? tint : Color(white: 0.23))
.frame(width: 40, height: 24)
.overlay(alignment: configuration.isOn ? .trailing : .leading) {
Circle().fill(.white).padding(2.5)
}
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityRepresentation {
Toggle(isOn: configuration.$isOn) { configuration.label }
}
}
}
/// 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())
}
}