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
@@ -0,0 +1,37 @@
|
||||
// Allow and Deny on the Smart Stack widget. WidgetKit runs the intent without opening the app, then
|
||||
// reloads the widget's timeline. A request someone already answered (409) counts as done.
|
||||
//
|
||||
// The app target compiles this file too, because WaitingView builds the buttons.
|
||||
|
||||
import AppIntents
|
||||
|
||||
struct RespondIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Answer an approval"
|
||||
static let isDiscoverable = false
|
||||
|
||||
@Parameter(title: "Host") var hostId: String
|
||||
@Parameter(title: "Thread") var threadId: String
|
||||
@Parameter(title: "Request") var requestId: String
|
||||
@Parameter(title: "Decision") var decision: String
|
||||
|
||||
init() {}
|
||||
|
||||
init(_ approval: WaitingEntry.Approval, decision: ApprovalDecision) {
|
||||
hostId = approval.hostId
|
||||
threadId = approval.threadId
|
||||
requestId = approval.requestId
|
||||
self.decision = decision.rawValue
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard let api = SharedState.api else { return .result() }
|
||||
do {
|
||||
try await api.respond(
|
||||
hostId: hostId, threadId: threadId,
|
||||
request: .decision(requestId: requestId, decision: ApprovalDecision(rawValue: decision)))
|
||||
} catch let error as GatewayError where error.isStale {
|
||||
// Answered already, on the watch or elsewhere. The reload shows what is next.
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// The watch face complication: how many threads are waiting on you, from `GET /v1/summary`. It
|
||||
// reads the device token and the demo flag from the shared Keychain group, and the app reloads it
|
||||
// whenever its own feed or a notification changes the counts.
|
||||
// The watch face complication and Smart Stack widget: what waits on you or what is working, from
|
||||
// `GET /v1/summary`. For the rectangular family it also reads the waiting thread, so the Smart Stack
|
||||
// can offer Allow and Deny. It reads the device token and the demo flag from the shared Keychain
|
||||
// group, and the app reloads it whenever its own feed or a notification changes the counts.
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
@@ -18,7 +19,7 @@ struct WaitingWidget: Widget {
|
||||
WaitingView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Waiting on you")
|
||||
.description("Threads waiting for your approval or answer.")
|
||||
.description("Threads waiting for your approval or answer, or what is working now.")
|
||||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner])
|
||||
}
|
||||
}
|
||||
@@ -36,29 +37,26 @@ struct WaitingProvider: TimelineProvider {
|
||||
completion(.sample)
|
||||
return
|
||||
}
|
||||
Task { completion(await Self.load()) }
|
||||
let family = context.family
|
||||
Task { completion(await Self.load(family)) }
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<WaitingEntry>) -> Void) {
|
||||
let family = context.family
|
||||
Task {
|
||||
let entry = await Self.load()
|
||||
let entry = await Self.load(family)
|
||||
completion(Timeline(entries: [entry], policy: .after(entry.date.addingTimeInterval(Self.refreshInterval))))
|
||||
}
|
||||
}
|
||||
|
||||
static func load() async -> WaitingEntry {
|
||||
let api: any GatewayAPI
|
||||
if SharedState.demo {
|
||||
api = DemoGateway.shared
|
||||
} else if let credentials = DeviceStore.load() {
|
||||
api = GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
|
||||
} else {
|
||||
return WaitingEntry(date: .now, content: .notPaired)
|
||||
}
|
||||
/// The summary, and for the rectangular family the waiting approval's Allow and Deny.
|
||||
static func load(_ family: WidgetFamily) async -> WaitingEntry {
|
||||
guard let api = SharedState.api else { return WaitingEntry(date: .now, content: .notPaired) }
|
||||
do {
|
||||
let summary = try await api.summary()
|
||||
SharedState.lastSummary = summary
|
||||
return WaitingEntry(date: .now, content: .counts(summary, stale: false))
|
||||
let approval = family == .accessoryRectangular ? await WaitingEntry.Approval.load(for: summary, api: api) : nil
|
||||
return WaitingEntry(date: .now, content: .counts(summary, stale: false), approval: approval)
|
||||
} catch GatewayError.unauthorized {
|
||||
return WaitingEntry(date: .now, content: .notPaired)
|
||||
} catch {
|
||||
@@ -69,9 +67,12 @@ struct WaitingProvider: TimelineProvider {
|
||||
}
|
||||
|
||||
private func entry(_ needsYou: Int, _ working: Int, next: Bool = true) -> WaitingEntry {
|
||||
let thread = Summary.Next(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini")
|
||||
let thread = Summary.Next(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test --filter ocr")
|
||||
let current = Summary.Current(
|
||||
hostId: "mac-mini", threadId: "t-scaffold", title: "Watch app scaffold",
|
||||
activity: Activity(kind: .command, text: "bun test", startedAt: .now.addingTimeInterval(-42)))
|
||||
return WaitingEntry(date: .now, content: .counts(
|
||||
Summary(needsYou: needsYou, working: working, next: needsYou > 0 && next ? thread : nil, hostsOffline: 1),
|
||||
Summary(needsYou: needsYou, working: working, next: needsYou > 0 && next ? thread : nil, current: working > 0 ? current : nil, hostsOffline: 1),
|
||||
stale: false))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user