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