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

+5
View File
@@ -44,6 +44,7 @@ enum GatewayError: LocalizedError, Equatable {
/// serves fixtures, so every screen works without a live gateway.
protocol GatewayAPI: Sendable {
func feed() async throws -> Feed
func summary() async throws -> Summary
func thread(hostId: String, threadId: String) async throws -> ThreadDetail
func sendMessage(hostId: String, threadId: String, text: String) async throws
func respond(hostId: String, threadId: String, request: RespondRequest) async throws
@@ -68,6 +69,10 @@ struct GatewayClient: GatewayAPI {
try await call("GET", ["v1", "feed"])
}
func summary() async throws -> Summary {
try await call("GET", ["v1", "summary"])
}
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
try await call("GET", ["v1", "threads", hostId, threadId])
}
+66 -1
View File
@@ -80,6 +80,8 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
var question: String
var options: [Choice]
var allowCustomAnswer: Bool
/// When true, the answer is an array of option values.
var multiSelect = false
}
var id: String
@@ -113,6 +115,24 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
}
}
extension PendingRequest.Question {
private enum CodingKeys: String, CodingKey {
case id, header, question, options, allowCustomAnswer, multiSelect
}
/// `multiSelect` is newer than the rest, so a gateway that leaves it out means single-select.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
header: try container.decode(String.self, forKey: .header),
question: try container.decode(String.self, forKey: .question),
options: try container.decode([Choice].self, forKey: .options),
allowCustomAnswer: try container.decode(Bool.self, forKey: .allowCustomAnswer),
multiSelect: try container.decodeIfPresent(Bool.self, forKey: .multiSelect) ?? false)
}
}
struct FeedItem: Codable, Hashable, Sendable, Identifiable {
var hostId: String
var hostLabel: String
@@ -136,6 +156,37 @@ struct Feed: Codable, Hashable, Sendable {
var hosts: [HostSummary]
}
/// `GET /v1/summary`: the counts behind the complication.
struct Summary: Codable, Hashable, Sendable {
struct Next: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
var hostLabel: String
var route: Route { .thread(hostId: hostId, threadId: threadId) }
}
var needsYou: Int
var working: Int
/// The thread that has waited longest for you.
var next: Next?
var hostsOffline: Int
}
extension Summary {
/// The same counts worked out from a feed. The feed lists needs-you oldest request first, so its
/// first item is the one that has waited longest.
init(_ feed: Feed) {
let next = feed.needsYou.first
self.init(
needsYou: feed.needsYou.count,
working: feed.working.count,
next: next.map { Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel) },
hostsOffline: feed.hosts.filter { $0.status != .online }.count)
}
}
struct Message: Codable, Hashable, Sendable, Identifiable {
struct Role: RawRepresentable, Codable, Hashable, Sendable {
let rawValue: String
@@ -203,8 +254,22 @@ struct SendMessageRequest: Codable, Sendable {
}
enum RespondRequest: Encodable, Sendable {
/// One question's answer: an option value or typed text, or option values for a multi-select question.
enum Answer: Encodable, Hashable, Sendable {
case text(String)
case values([String])
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .text(text): try container.encode(text)
case let .values(values): try container.encode(values)
}
}
}
case decision(requestId: String, decision: ApprovalDecision)
case answers(requestId: String, answers: [String: String])
case answers(requestId: String, answers: [String: Answer])
private enum CodingKeys: String, CodingKey { case requestId, decision, answers }
+42
View File
@@ -0,0 +1,42 @@
// Screens the app can open, and the `wristcode://` URLs the complication uses to open them.
import Foundation
enum Route: Hashable, Sendable {
case thread(hostId: String, threadId: String)
case newThread
static let scheme = "wristcode"
/// `wristcode://thread/<hostId>/<threadId>` or `wristcode://new`.
var url: URL {
var components = URLComponents()
components.scheme = Self.scheme
switch self {
case let .thread(hostId, threadId):
components.host = "thread"
components.percentEncodedPath = "/" + [hostId, threadId]
.map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 }
.joined(separator: "/")
case .newThread:
components.host = "new"
}
return components.url!
}
/// The route a `wristcode://` URL names, or nil when it names none.
init?(url: URL) {
guard url.scheme == Self.scheme,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return nil }
let parts = components.percentEncodedPath.split(separator: "/").map { $0.removingPercentEncoding ?? String($0) }
switch components.host {
case "thread" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }:
self = .thread(hostId: parts[0], threadId: parts[1])
case "new":
self = .newThread
default:
return nil
}
}
}
+47 -8
View File
@@ -1,14 +1,12 @@
// App-wide state: whether the watch is paired, the navigation path, and the latest feed.
// App-wide state: whether the watch is paired, the navigation path, and the latest feed. It also
// keeps the complication current: it shares the mode and latest counts through the App Group and
// reloads the complication's timeline when they change.
import Foundation
import Observation
import UserNotifications
import WatchKit
enum Route: Hashable, Sendable {
case thread(hostId: String, threadId: String)
case newThread
}
import WidgetKit
@MainActor @Observable
final class AppModel {
@@ -22,12 +20,16 @@ final class AppModel {
/// The one instance the app delegate and notification handler share with the views.
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
var mode: Mode
var mode: Mode {
didSet { if mode != oldValue { shareMode() } }
}
var path: [Route] = []
private(set) var feed: Feed?
private(set) var feedError: String?
private var apnsToken: String?
private var refreshing = false
/// The counts the complication was last told about.
private var sharedSummary: Summary?
init(mode: Mode, feed: Feed? = nil, path: [Route] = []) {
self.mode = mode
@@ -38,6 +40,7 @@ final class AppModel {
/// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that
/// screen, which is how the screenshots in watch/screenshots are taken.
convenience init(launchArguments args: [String]) {
DeviceStore.migrateToSharedGroup()
let demo = args.contains("-demo")
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
var path: [Route] = []
@@ -49,6 +52,7 @@ final class AppModel {
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
}
self.init(mode: mode, path: path)
shareMode()
}
var isPaired: Bool {
@@ -114,8 +118,10 @@ final class AppModel {
refreshing = true
defer { refreshing = false }
do {
feed = try await api.feed()
let feed = try await api.feed()
self.feed = feed
feedError = nil
share(Summary(feed))
} catch {
feedError = message(for: error)
}
@@ -125,6 +131,38 @@ final class AppModel {
path = [route]
}
/// Opens a `wristcode://` link from the complication.
func open(url: URL) {
guard mode != .unpaired, let route = Route(url: url) else { return }
open(route)
}
// MARK: - Complication
/// Tells the complication about the mode. Counts from another mode, or from before signing
/// out, are forgotten.
private func shareMode() {
let demo = mode == .demo
if SharedState.demo != demo || mode == .unpaired { SharedState.lastSummary = nil }
SharedState.demo = demo
sharedSummary = nil
reloadComplication()
}
/// Hands the complication fresh counts. The feed refreshes every 10 s while on screen, so the
/// timeline only reloads when the counts or the next thread change.
private func share(_ summary: Summary) {
guard summary != sharedSummary else { return }
sharedSummary = summary
SharedState.lastSummary = summary
reloadComplication()
}
/// Asks the complication to fetch the summary again, as after a push or a notification action.
func reloadComplication() {
WidgetCenter.shared.reloadAllTimelines()
}
// MARK: - Notifications
func enableNotifications() async {
@@ -156,6 +194,7 @@ final class AppModel {
}
func notificationArrived() {
reloadComplication()
Task { await refreshFeed() }
}
}
+52 -3
View File
@@ -1,5 +1,9 @@
// The device token and gateway URL, kept in the Keychain. They are readable after first unlock, so
// a notification action handled in the background can still call the gateway.
//
// The items live in the App Group's Keychain access group, so the complication can read them too.
// Builds before the complication kept them in the app's own access group; `migrateToSharedGroup()`
// moves them on launch, so a paired watch stays paired.
import Foundation
import Security
@@ -12,6 +16,9 @@ struct Credentials: Equatable, Sendable {
enum DeviceStore {
static let defaultGatewayURL = "https://wristcode.gbrown.org"
/// The App Group the app and the complication share. It doubles as the Keychain access group.
static let appGroup = "group.org.gbrown.wristcode"
private static let service = "org.gbrown.wristcode"
private static let tokenAccount = "deviceToken"
private static let urlAccount = "gatewayURL"
@@ -39,6 +46,23 @@ enum DeviceStore {
read(urlAccount) ?? defaultGatewayURL
}
/// Moves items an older build saved in the app's own access group into the shared one. Does
/// nothing once they have moved. If the move fails the old items stay, and `load()` still finds
/// them, so the app keeps working and only the complication goes without.
static func migrateToSharedGroup() {
for account in [tokenAccount, urlAccount] where readShared(account) == nil {
guard let legacy = readAnyGroup(account), legacy.group != appGroup else { continue }
var item = query(account)
item[kSecAttrAccessGroup as String] = appGroup
item[kSecValueData as String] = Data(legacy.value.utf8)
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { continue }
var old = query(account)
old[kSecAttrAccessGroup as String] = legacy.group
SecItemDelete(old as CFDictionary)
}
}
/// Turns what someone typed into a gateway base URL: trims it, adds `https://` when there is
/// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL.
static func normalizeGatewayURL(_ input: String) -> URL? {
@@ -64,8 +88,17 @@ enum DeviceStore {
]
}
private static func sharedQuery(_ account: String) -> [String: Any] {
query(account).merging([kSecAttrAccessGroup as String: appGroup]) { _, new in new }
}
/// The shared item, or failing that one an older build left in the app's own group.
private static func read(_ account: String) -> String? {
var query = query(account)
readShared(account) ?? readAnyGroup(account)?.value
}
private static func readShared(_ account: String) -> String? {
var query = sharedQuery(account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
@@ -75,19 +108,35 @@ enum DeviceStore {
return String(data: data, encoding: .utf8)
}
/// An item in any access group this process can read, with the group it is in.
private static func readAnyGroup(_ account: String) -> (value: String, group: String)? {
var query = query(account)
query[kSecReturnData as String] = true
query[kSecReturnAttributes as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let attributes = result as? [String: Any],
let data = attributes[kSecValueData as String] as? Data,
let value = String(data: data, encoding: .utf8)
else { return nil }
return (value, attributes[kSecAttrAccessGroup as String] as? String ?? "")
}
private static func write(_ value: String, account: String) {
let data = Data(value.utf8)
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let status = SecItemUpdate(query(account) as CFDictionary, attributes as CFDictionary)
let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
let item = query(account).merging(attributes) { _, new in new }
let item = sharedQuery(account).merging(attributes) { _, new in new }
SecItemAdd(item as CFDictionary, nil)
}
}
/// Deletes the item from every access group, so a leftover from an older build goes too.
private static func delete(_ account: String) {
SecItemDelete(query(account) as CFDictionary)
}
+27 -1
View File
@@ -29,6 +29,10 @@ actor DemoGateway: GatewayAPI {
return Feed(needsYou: needsYou, working: working, recent: Array(recent.prefix(10)), hosts: hosts)
}
func summary() async throws -> Summary {
Summary(try await feed())
}
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
return ThreadDetail(item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking)
@@ -154,6 +158,24 @@ struct DemoFixtures {
allowCustomAnswer: true),
]))
let release = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-release", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Release checklist", phase: .waitingForInput,
updatedAt: ago(6), lastMessage: "The build is ready. Which checks should run first?",
request: PendingRequest(
id: "r-release", kind: "user_input", prompt: nil, reason: nil, options: [],
questions: [
.init(
id: "checks", header: "Checks", question: "Which checks should run before upload?",
options: [
.init(label: "Unit tests", value: "unit"),
.init(label: "UI tests", value: "ui"),
.init(label: "Lint", value: "lint"),
.init(label: "Archive", value: "archive"),
],
allowCustomAnswer: true, multiSelect: true),
]))
let scaffold = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5),
@@ -174,7 +196,7 @@ struct DemoFixtures {
projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil)
items = [approval, question, scaffold, containerfile, nextjs, cdn]
items = [approval, question, release, scaffold, containerfile, nextjs, cdn]
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
Message(id: id, role: role, text: text, createdAt: ago(minutes))
@@ -190,6 +212,10 @@ struct DemoFixtures {
m("b1", .user, 10, "Add pairing codes to the gateway."),
m("b2", .assistant, 1, "Two ways to store pairing codes. Hashing is safer, plain is simpler to debug."),
],
release.id: [
m("r1", .user, 25, "Get the watch app ready for TestFlight."),
m("r2", .assistant, 6, "The build is ready. Which checks should run first?"),
],
scaffold.id: [
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
+11
View File
@@ -18,6 +18,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>org.gbrown.wristcode</string>
<key>CFBundleURLSchemes</key>
<array>
<string>wristcode</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
@@ -103,6 +103,7 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
return
case let .open(route):
if let route { model.open(route) } else { model.path = [] }
model.reloadComplication()
await model.refreshFeed()
return
case let .respond(hostId, threadId, requestId, decision):
@@ -118,6 +119,7 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
}
}
model.reloadComplication()
await model.refreshFeed()
}
+22
View File
@@ -0,0 +1,22 @@
// What the app tells the complication through the App Group's defaults: whether it is in demo mode,
// and the last summary it saw, which the complication shows when it cannot reach the gateway.
import Foundation
enum SharedState {
private static var defaults: UserDefaults { UserDefaults(suiteName: DeviceStore.appGroup) ?? .standard }
private static let demoKey = "demo"
private static let summaryKey = "summary"
/// True while the app is in demo mode, so the complication shows the demo counts too.
static var demo: Bool {
get { defaults.bool(forKey: demoKey) }
set { defaults.set(newValue, forKey: demoKey) }
}
/// The last summary the app or the complication saw, or nil after signing out.
static var lastSummary: Summary? {
get { defaults.data(forKey: summaryKey).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } }
set { defaults.set(newValue.flatMap { try? JSON.encoder().encode($0) }, forKey: summaryKey) }
}
}
+66 -9
View File
@@ -13,7 +13,9 @@ struct ThreadView: View {
@State private var error: String?
@State private var sending = false
@State private var visible = false
@State private var answers: [String: String] = [:]
@State private var answers: [String: RespondRequest.Answer] = [:]
/// Options ticked so far on multi-select questions, by question id.
@State private var selections: [String: Set<String>] = [:]
@State private var scrolledToLatest = false
var body: some View {
@@ -152,19 +154,29 @@ struct ThreadView: View {
Text(question.question)
.font(.system(size: 14, weight: .semibold))
.padding(.top, 1)
if question.multiSelect {
Text("Choose any")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.top, 1)
}
VStack(spacing: 6) {
ForEach(question.options, id: \.value) { choice in
let chosen = answers[question.id] == choice.value
Button(choice.label) { answer(request, question.id, choice.value) }
.buttonStyle(ChoiceButtonStyle(
fill: chosen ? Theme.you : Theme.control,
foreground: chosen ? .black : .white))
if question.multiSelect {
multiSelectOptions(request, question)
} else {
ForEach(question.options, id: \.value) { choice in
let chosen = answers[question.id] == .text(choice.value)
Button(choice.label) { answer(request, question.id, .text(choice.value)) }
.buttonStyle(ChoiceButtonStyle(
fill: chosen ? Theme.you : Theme.control,
foreground: chosen ? .black : .white))
}
}
if question.allowCustomAnswer {
TextFieldLink(prompt: Text(question.question)) {
Text("Other…")
} onSubmit: { text in
answer(request, question.id, text)
answer(request, question.id, .text(text))
}
.buttonStyle(ChoiceButtonStyle())
}
@@ -175,6 +187,37 @@ struct ThreadView: View {
}
}
/// Checkmark toggles, then Send. With more questions still open, the button says Done and the
/// answers go out together once the last one is in.
@ViewBuilder
private func multiSelectOptions(_ request: PendingRequest, _ question: PendingRequest.Question) -> some View {
let picked = selections[question.id, default: []]
ForEach(question.options, id: \.value) { choice in
let checked = picked.contains(choice.value)
Button { toggle(question.id, choice.value) } label: {
HStack(spacing: 6) {
Image(systemName: checked ? "checkmark.circle.fill" : "circle")
Text(choice.label)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
}
}
.buttonStyle(ChoiceButtonStyle(
fill: checked ? Theme.you : Theme.control,
foreground: checked ? .black : .white))
.accessibilityAddTraits(checked ? .isSelected : [])
}
let last = request.questions.allSatisfy { $0.id == question.id || answers[$0.id] != nil }
Button(last ? "Send" : "Done") {
let values = question.options.map(\.value).filter(picked.contains)
answer(request, question.id, .values(values))
}
.buttonStyle(PillButtonStyle(
fill: picked.isEmpty ? Theme.control : Theme.you,
foreground: picked.isEmpty ? Theme.done : .black))
.disabled(picked.isEmpty)
}
private func statusLine(_ item: FeedItem) -> String {
let place = "\(item.hostLabel) · \(item.projectTitle)"
return switch item.phase {
@@ -224,13 +267,20 @@ struct ThreadView: View {
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) }
}
private func toggle(_ questionId: String, _ value: String) {
if selections[questionId, default: []].remove(value) == nil {
selections[questionId, default: []].insert(value)
}
}
/// Records one answer. Once every question has one, sends them together.
private func answer(_ request: PendingRequest, _ questionId: String, _ value: String) {
private func answer(_ request: PendingRequest, _ questionId: String, _ value: RespondRequest.Answer) {
answers[questionId] = value
let all = request.questions.allSatisfy { answers[$0.id] != nil }
guard all else { return }
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
answers = [:]
selections = [:]
respond(.answers(requestId: request.id, answers: payload))
}
@@ -307,3 +357,10 @@ private struct QuickReplies: View {
}
.environment(AppModel.preview())
}
#Preview("Multi-select") {
NavigationStack {
ThreadView(hostId: "mac-mini", threadId: "t-release")
}
.environment(AppModel.preview())
}
+4
View File
@@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.gbrown.wristcode</string>
</array>
</dict>
</plist>
+1
View File
@@ -40,6 +40,7 @@ struct RootView: View {
}
.fontDesign(.rounded)
.tint(Theme.work)
.onOpenURL { model.open(url: $0) }
}
}