Watch: what changed on Recent cards and the thread screen, search in All threads, a switching complication with Allow and Deny in the Smart Stack, and question options on notifications

This commit is contained in:
gib committed 2026-11-02 13:56:10 -05:00
1 parent f8241e42bb
commit bfcbb69550
29 files changed
+1100 -137

No files matched your search

+217 -47
View File
@@ -1,6 +1,12 @@
// 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
@@ -16,8 +22,20 @@ struct WaitingEntry: TimelineEntry {
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? {
@@ -27,21 +45,59 @@ struct WaitingEntry: TimelineEntry {
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"),
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(next?.route.url)
.widgetURL(route?.url)
.containerBackground(for: .widget) {
if waiting > 0 {
LinearGradient(colors: [Theme.you.opacity(0.3), Theme.card], startPoint: .top, endPoint: .bottom)
@@ -67,14 +123,15 @@ struct WaitingView: View {
private var circular: some View {
ZStack {
AccessoryWidgetBackground()
if let summary, summary.needsYou > 0 {
if let summary, summary.needsYou > 0 || summary.working > 0 {
let waits = summary.needsYou > 0
VStack(spacing: -3) {
Text("\(summary.needsYou)")
Text("\(waits ? summary.needsYou : summary.working)")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundStyle(Theme.you)
.foregroundStyle(waits ? Theme.you : Theme.work)
.minimumScaleFactor(0.6)
.widgetAccentable()
Text("waiting")
Text(waits ? "waiting" : "working")
.font(.system(size: 9, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
}
@@ -95,42 +152,10 @@ struct WaitingView: View {
private var rectangular: some View {
VStack(alignment: .leading, spacing: 0) {
if let summary {
if let next = summary.next, summary.needsYou > 0 {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(summary.needsYou)")
.font(.system(size: 22, weight: .bold, design: .rounded))
Text("waiting")
.font(.system(size: 14, weight: .semibold, design: .rounded))
Spacer(minLength: 0)
staleMark
}
.foregroundStyle(Theme.you)
.widgetAccentable()
Text(next.title)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.lineLimit(1)
Text(next.hostLabel)
.font(.system(size: 13, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
HStack(spacing: 4) {
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)
}
switch ComplicationMode(summary) {
case let .waiting(next): waitingRows(summary, next)
case let .working(current): workingRows(summary, current)
case .idle: idleRows(summary)
}
} else {
Text("WristCode")
@@ -143,22 +168,148 @@ struct WaitingView: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.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 {
Text("\(summary.needsYou)")
let waits = summary.needsYou > 0
Text("\(waits ? summary.needsYou : summary.working)")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(summary.needsYou > 0 ? Theme.you : Color.secondary)
.foregroundStyle(waits ? Theme.you : summary.working > 0 ? Theme.work : Color.secondary)
.widgetAccentable()
} else {
Image(systemName: unpairedSymbol)
@@ -168,7 +319,7 @@ struct WaitingView: View {
}
.widgetLabel {
if let summary {
Text(summary.needsYou > 0 ? "waiting" : "\(summary.working) working")
Text(summary.needsYou > 0 ? "waiting" : "working")
} else {
Text("WristCode")
}
@@ -185,12 +336,31 @@ struct WaitingView: View {
private var waiting: Int { summary?.needsYou ?? 0 }
private var next: Summary.Next? { waiting > 0 ? summary?.next : nil }
/// 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 {