// One view per complication family, in mock B's colors: amber when something needs you, teal for // work in progress, and nothing that animates. Tinted faces use the accented parts only. // // The rectangular slot switches, as mock C does: while anything waits on you it shows the counts, // the thread that has waited longest and what it wants; otherwise the working thread changed most // recently, how long its step has run and what it is doing. In the Smart Stack, where the widget // has its container background, an approval also gets Allow and Deny, which answer through // `RespondIntent` without opening the app. // // The app target compiles this file too, so the snapshot tests can render the complication. import SwiftUI import WidgetKit struct WaitingEntry: TimelineEntry { enum Content: Equatable { /// Counts from the gateway. `stale` when they are the last ones seen because the gateway /// could not be reached. case counts(Summary, stale: Bool) case notPaired /// The gateway could not be reached and there is nothing saved to show. case unavailable } /// The Allow and Deny pair for the summary's `next` thread, when it waits on an approval that /// offers both. The thread named is the one that answers it, a subagent when one asked. struct Approval: Equatable, Sendable { var hostId: String var threadId: String var requestId: String var allow: PendingRequest.Option var deny: PendingRequest.Option } var date: Date var content: Content /// Only looked up for the rectangular family, which the Smart Stack uses. var approval: Approval? = nil /// Ranks the complication higher in the Smart Stack while something waits. var relevance: TimelineEntryRelevance? { guard case let .counts(summary, _) = content, summary.needsYou > 0 else { return nil } return TimelineEntryRelevance(score: Float(summary.needsYou)) } static let sample = WaitingEntry(date: .now, content: .counts(Summary( needsYou: 2, working: 3, next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test --filter ocr"), hostsOffline: 0), stale: false)) } extension WaitingEntry.Approval { /// Reads `next`'s pending request. Nil unless it is an approval with Allow and Deny, or when the /// thread cannot be read, in which case the widget opens the thread instead. static func load(for summary: Summary, api: any GatewayAPI) async -> Self? { guard summary.needsYou > 0, let next = summary.next, let detail = try? await api.thread(hostId: next.hostId, threadId: next.threadId), let request = detail.item.request, !request.isQuestion, let quick = request.quickOptions else { return nil } let target = detail.item.requestThread return Self(hostId: target.hostId, threadId: target.threadId, requestId: request.id, allow: quick.allow, deny: quick.deny) } } /// What the rectangular slot shows. enum ComplicationMode: Equatable { /// Something waits on you: the counts, then the thread that has waited longest and what it wants. case waiting(Summary.Next) /// Nothing waits: the working thread changed most recently and what it is doing. case working(Summary.Current) /// Nothing waits on you and nothing works, or an older gateway sent no thread to name. case idle init(_ summary: Summary) { if summary.needsYou > 0, let next = summary.next { self = .waiting(next) } else if let current = summary.current { self = .working(current) } else { self = .idle } } } struct WaitingView: View { @Environment(\.widgetFamily) private var widgetFamily @Environment(\.showsWidgetContainerBackground) private var showsBackground var entry: WaitingEntry /// Overrides the environment's family, for rendering outside a widget. var familyOverride: WidgetFamily? /// Overrides whether this is the Smart Stack, for rendering outside a widget. var inStackOverride: Bool? private var family: WidgetFamily { familyOverride ?? widgetFamily } /// Watch faces drop the container background, and the Smart Stack keeps it. private var inStack: Bool { inStackOverride ?? showsBackground } var body: some View { content .widgetURL(route?.url) .containerBackground(for: .widget) { if waiting > 0 { LinearGradient(colors: [Theme.you.opacity(0.3), Theme.card], startPoint: .top, endPoint: .bottom) } else { Theme.card } } } @ViewBuilder private var content: some View { switch family { case .accessoryCircular: circular case .accessoryRectangular: rectangular case .accessoryInline: inline case .accessoryCorner: corner default: circular } } // MARK: - Families private var circular: some View { ZStack { AccessoryWidgetBackground() if let summary, summary.needsYou > 0 || summary.working > 0 { let waits = summary.needsYou > 0 VStack(spacing: -3) { Text("\(waits ? summary.needsYou : summary.working)") .font(.system(size: 26, weight: .bold, design: .rounded)) .foregroundStyle(waits ? Theme.you : Theme.work) .minimumScaleFactor(0.6) .widgetAccentable() Text(waits ? "waiting" : "working") .font(.system(size: 9, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) } } else if summary != nil { Image(systemName: "checkmark") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.secondary) } else { Image(systemName: unpairedSymbol) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(.secondary) } } .accessibilityElement(children: .ignore) .accessibilityLabel(spoken) } private var rectangular: some View { VStack(alignment: .leading, spacing: 0) { if let summary { switch ComplicationMode(summary) { case let .waiting(next): waitingRows(summary, next) case let .working(current): workingRows(summary, current) case .idle: idleRows(summary) } } else { Text("WristCode") .font(.system(size: 15, weight: .bold, design: .rounded)) .foregroundStyle(Theme.work) .widgetAccentable() Text(entry.content == .notPaired ? "Open the app to pair" : "Can't reach the gateway") .font(.system(size: 13, design: .rounded)) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .contain) } /// The counts, the thread that has waited longest, and the command it wants to run or its /// question. In the Smart Stack an approval adds Allow and Deny. @ViewBuilder private func waitingRows(_ summary: Summary, _ next: Summary.Next) -> some View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text("● \(summary.needsYou) waiting") .foregroundStyle(Theme.you) .widgetAccentable() Spacer(minLength: 0) if summary.working > 0 { Text("\(summary.working) working") .foregroundStyle(Theme.work) } staleMark } .font(.system(size: 12, weight: .semibold, design: .rounded)) .lineLimit(1) Text(next.title) .font(.system(size: 15, weight: .semibold, design: .rounded)) .lineLimit(1) .padding(.top, 1) if let request = next.request { Text(code: request) .font(.system(size: 12)) .foregroundStyle(.secondary) .lineLimit(inStack && entry.approval == nil ? 2 : 1) } else { Text(next.hostLabel) .font(.system(size: 13, design: .rounded)) .foregroundStyle(.secondary) .lineLimit(1) } if inStack, let approval = entry.approval { HStack(spacing: 5) { Button(intent: RespondIntent(approval, decision: approval.allow.decision)) { stackButton(approval.allow.label, fill: Theme.you, foreground: .black) } Button(intent: RespondIntent(approval, decision: approval.deny.decision)) { stackButton(approval.deny.label, fill: Theme.control, foreground: .white) } } .buttonStyle(.plain) .padding(.top, 4) } } /// The working thread changed most recently with how long its step has run, the step itself, and /// the waiting count under it. The elapsed time is as of the entry, since nothing here ticks. @ViewBuilder private func workingRows(_ summary: Summary, _ current: Summary.Current) -> some View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(current.title) .lineLimit(1) if let startedAt = current.activity?.startedAt { Text("· \(Format.elapsed(since: startedAt, now: entry.date))") .monospacedDigit() .fixedSize() } Spacer(minLength: 0) staleMark } .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(Theme.work) .widgetAccentable() if let activity = current.activity { HStack(alignment: .firstTextBaseline, spacing: 4) { Image(systemName: activity.kind.symbol) .font(.system(size: 10, weight: .bold)) .foregroundStyle(Theme.work) Text(code: activity.text) .font(.system(size: 12)) .lineLimit(inStack ? 2 : 1) } .padding(.top, 2) } else { Text(summary.working == 1 ? "1 working" : "\(summary.working) working") .font(.system(size: 13, design: .rounded)) .foregroundStyle(.secondary) .padding(.top, 2) } Text(summary.needsYou > 0 ? "● \(summary.needsYou) waiting" : "Nothing waiting") .font(.system(size: 12, weight: .semibold, design: .rounded)) .foregroundStyle(summary.needsYou > 0 ? Theme.you : .secondary) .padding(.top, 3) } private func idleRows(_ summary: Summary) -> some View { Group { HStack(spacing: 4) { if summary.needsYou > 0 { Text("● \(summary.needsYou) waiting") .foregroundStyle(Theme.you) } else { Image(systemName: "checkmark") Text("Nothing waiting") } Spacer(minLength: 0) staleMark } .font(.system(size: 14, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) Text("\(summary.working) working") .font(.system(size: 17, weight: .bold, design: .rounded)) .foregroundStyle(summary.working > 0 ? Theme.work : .secondary) .widgetAccentable() if summary.hostsOffline > 0 { Text(summary.hostsOffline == 1 ? "1 host offline" : "\(summary.hostsOffline) hosts offline") .font(.system(size: 13, design: .rounded)) .foregroundStyle(.secondary) } } } /// The count with the thread's title, then the counts alone, then the waiting count. private var inline: some View { ViewThatFits { Text(titled ?? spoken) Text(spoken) if let summary { Text("\(summary.needsYou) waiting") } } } /// "2 waiting · Receipt OCR" or "3 working · Phase 3 screens", for the inline slot. private var titled: String? { guard let summary else { return nil } return switch ComplicationMode(summary) { case let .waiting(next): "\(summary.needsYou) waiting · \(next.title)" case let .working(current): "\(summary.working) working · \(current.title)" case .idle: nil } } private var corner: some View { Group { if let summary { let waits = summary.needsYou > 0 Text("\(waits ? summary.needsYou : summary.working)") .font(.system(size: 24, weight: .bold, design: .rounded)) .foregroundStyle(waits ? Theme.you : summary.working > 0 ? Theme.work : Color.secondary) .widgetAccentable() } else { Image(systemName: unpairedSymbol) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(.secondary) } } .widgetLabel { if let summary { Text(summary.needsYou > 0 ? "waiting" : "working") } else { Text("WristCode") } } .accessibilityLabel(spoken) } // MARK: - Pieces private var summary: Summary? { if case let .counts(summary, _) = entry.content { return summary } return nil } private var waiting: Int { summary?.needsYou ?? 0 } /// Where a tap goes: the thread waiting longest, else the working thread, else the feed. private var route: Route? { guard let summary else { return nil } return switch ComplicationMode(summary) { case let .waiting(next): next.route case let .working(current): current.route case .idle: nil } } private var unpairedSymbol: String { entry.content == .notPaired ? "applewatch.slash" : "wifi.slash" } /// A Smart Stack button's label, drawn here because widgets show a button's label as is. private func stackButton(_ title: String, fill: Color, foreground: Color) -> some View { Text(title) .font(.system(size: 12, weight: .bold, design: .rounded)) .foregroundStyle(foreground) .lineLimit(1) .frame(maxWidth: .infinity) .padding(.vertical, 4) .background(fill, in: Capsule()) } /// Shown beside the count when it is the last one seen, not a fresh one. @ViewBuilder private var staleMark: some View { if case .counts(_, stale: true) = entry.content { Image(systemName: "wifi.slash") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) } } /// "2 waiting · 3 working", the inline text and the VoiceOver label. private var spoken: String { switch entry.content { case let .counts(summary, _): "\(summary.needsYou) waiting · \(summary.working) working" case .notPaired: "WristCode not paired" case .unavailable: "WristCode offline" } } }