Files
wristcode/watch/WristCodeWidget/WaitingWidget.swift
T
gibandClaude Opus 5.5 27469dbc77 Watch: share with the complication through a Keychain group, not an App Group
Automatic signing with an App Store Connect API key registers bundle IDs and
profiles but cannot create or assign App Groups, so the archive failed. A
keychain-access-groups entry needs no portal setup. The demo flag and last
summary move into the same group; tests cover the round trip and the
migration of a pre-complication token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:15:31 -05:00

106 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 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
@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 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))
}
}
}
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")
return WaitingEntry(date: .now, content: .counts(
Summary(needsYou: needsYou, working: working, next: needsYou > 0 && next ? thread : nil, hostsOffline: 1),
stale: false))
}
#Preview("Circular", as: .accessoryCircular) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
WaitingEntry(date: .now, content: .notPaired)
}
#Preview("Rectangular", as: .accessoryRectangular) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
WaitingEntry(date: .now, content: .notPaired)
}
#Preview("Inline", as: .accessoryInline) {
WaitingWidget()
} timeline: {
entry(2, 3)
}
#Preview("Corner", as: .accessoryCorner) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
}