383 lines
15 KiB
Swift
383 lines
15 KiB
Swift
// 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"
|
|
}
|
|
}
|
|
}
|