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

+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>WristCode</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+217
View File
@@ -0,0 +1,217 @@
// 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.
import SwiftUI
import WidgetKit
struct WaitingView: View {
@Environment(\.widgetFamily) private var family
var entry: WaitingEntry
var body: some View {
content
.widgetURL(next?.route.url)
.containerBackground(for: .widget) {
if waiting > 0 {
LinearGradient(colors: [Theme.you.opacity(0.3), Theme.card], startPoint: .top, endPoint: .bottom)
} else {
Theme.card
}
}
}
@ViewBuilder
private var content: some View {
switch family {
case .accessoryCircular: circular
case .accessoryRectangular: rectangular
case .accessoryInline: inline
case .accessoryCorner: corner
default: circular
}
}
// MARK: - Families
private var circular: some View {
ZStack {
AccessoryWidgetBackground()
if let summary, summary.needsYou > 0 {
VStack(spacing: -3) {
Text("\(summary.needsYou)")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundStyle(Theme.you)
.minimumScaleFactor(0.6)
.widgetAccentable()
Text("waiting")
.font(.system(size: 9, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
}
} else if summary != nil {
Image(systemName: "checkmark")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.secondary)
} else {
Image(systemName: unpairedSymbol)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.secondary)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(spoken)
}
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)
}
}
} else {
Text("WristCode")
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(Theme.work)
.widgetAccentable()
Text(entry.content == .notPaired ? "Open the app to pair" : "Can't reach the gateway")
.font(.system(size: 13, design: .rounded))
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
private var inline: some View {
ViewThatFits {
Text(spoken)
if let summary { Text("\(summary.needsYou) waiting") }
}
}
private var corner: some View {
Group {
if let summary {
Text("\(summary.needsYou)")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(summary.needsYou > 0 ? Theme.you : Color.secondary)
.widgetAccentable()
} else {
Image(systemName: unpairedSymbol)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.secondary)
}
}
.widgetLabel {
if let summary {
Text(summary.needsYou > 0 ? "waiting" : "\(summary.working) working")
} else {
Text("WristCode")
}
}
.accessibilityLabel(spoken)
}
// MARK: - Pieces
private var summary: Summary? {
if case let .counts(summary, _) = entry.content { return summary }
return nil
}
private var waiting: Int { summary?.needsYou ?? 0 }
private var next: Summary.Next? { waiting > 0 ? summary?.next : nil }
private var unpairedSymbol: String {
entry.content == .notPaired ? "applewatch.slash" : "wifi.slash"
}
/// Shown beside the count when it is the last one seen, not a fresh one.
@ViewBuilder
private var staleMark: some View {
if case .counts(_, stale: true) = entry.content {
Image(systemName: "wifi.slash")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
}
}
/// "2 waiting · 3 working", the inline text and the VoiceOver label.
private var spoken: String {
switch entry.content {
case let .counts(summary, _): "\(summary.needsYou) waiting · \(summary.working) working"
case .notPaired: "WristCode not paired"
case .unavailable: "WristCode offline"
}
}
}
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)
}
+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))
}
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.gbrown.wristcode</string>
</array>
</dict>
</plist>