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]>
95 lines
3.5 KiB
Swift
95 lines
3.5 KiB
Swift
// 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))
|
|
}
|
|
}
|
|
}
|