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:
1 parent
f8241e42bb
commit
bfcbb69550
29 files changed
+1100
-137
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user