Watch: waiting-count complication and multi-select questions

A WidgetKit extension (accessoryCircular, Rectangular, Inline, Corner) shows
GET /v1/summary. The device token moves to the App Group's Keychain access
group on launch so the extension can read it without re-pairing; the app
reloads the timeline when its feed counts change and on notifications.
Multi-select questions toggle options and send an array of values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
gibandClaude Opus 5.5 committed 2026-11-01 11:06:38 -05:00
1 parent d4d67295c6
commit 0994fd7d5a
18 files changed
+800 -23

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
// The watch face complication: how many threads are waiting on you, from `GET /v1/summary`. It
// reads the device token from the shared Keychain group and the demo flag from the App Group, and
// the app reloads it whenever its own feed or a notification changes the counts.
import SwiftUI
import WidgetKit
@main
struct WristCodeWidgets: WidgetBundle {
var body: some Widget {
WaitingWidget()
}
}
struct WaitingWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "WaitingCount", provider: WaitingProvider()) { entry in
WaitingView(entry: entry)
}
.configurationDisplayName("Waiting on you")
.description("Threads waiting for your approval or answer.")
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner])
}
}
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
}
var date: Date
var content: Content
/// 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"),
hostsOffline: 0), stale: false))
}
struct WaitingProvider: TimelineProvider {
/// How long until WidgetKit asks again. The app reloads sooner when it learns something.
static let refreshInterval: TimeInterval = 15 * 60
func placeholder(in context: Context) -> WaitingEntry {
.sample
}
func getSnapshot(in context: Context, completion: @escaping @Sendable (WaitingEntry) -> Void) {
if context.isPreview {
completion(.sample)
return
}
Task { completion(await Self.load()) }
}
func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<WaitingEntry>) -> Void) {
Task {
let entry = await Self.load()
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)
}
do {
let summary = try await api.summary()
SharedState.lastSummary = summary
return WaitingEntry(date: .now, content: .counts(summary, stale: false))
} catch GatewayError.unauthorized {
return WaitingEntry(date: .now, content: .notPaired)
} catch {
guard let last = SharedState.lastSummary else { return WaitingEntry(date: .now, content: .unavailable) }
return WaitingEntry(date: .now, content: .counts(last, stale: true))
}
}
}