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. /// serves fixtures, so every screen works without a live gateway.
protocol GatewayAPI: Sendable { protocol GatewayAPI: Sendable {
func feed() async throws -> Feed func feed() async throws -> Feed
func summary() async throws -> Summary
func thread(hostId: String, threadId: String) async throws -> ThreadDetail func thread(hostId: String, threadId: String) async throws -> ThreadDetail
func sendMessage(hostId: String, threadId: String, text: String) async throws func sendMessage(hostId: String, threadId: String, text: String) async throws
func respond(hostId: String, threadId: String, request: RespondRequest) 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"]) 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 { func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
try await call("GET", ["v1", "threads", hostId, threadId]) 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 question: String
var options: [Choice] var options: [Choice]
var allowCustomAnswer: Bool var allowCustomAnswer: Bool
/// When true, the answer is an array of option values.
var multiSelect = false
} }
var id: String 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 { struct FeedItem: Codable, Hashable, Sendable, Identifiable {
var hostId: String var hostId: String
var hostLabel: String var hostLabel: String
@@ -136,6 +156,37 @@ struct Feed: Codable, Hashable, Sendable {
var hosts: [HostSummary] 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 Message: Codable, Hashable, Sendable, Identifiable {
struct Role: RawRepresentable, Codable, Hashable, Sendable { struct Role: RawRepresentable, Codable, Hashable, Sendable {
let rawValue: String let rawValue: String
@@ -203,8 +254,22 @@ struct SendMessageRequest: Codable, Sendable {
} }
enum RespondRequest: Encodable, 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 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 } 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 Foundation
import Observation import Observation
import UserNotifications import UserNotifications
import WatchKit import WatchKit
import WidgetKit
enum Route: Hashable, Sendable {
case thread(hostId: String, threadId: String)
case newThread
}
@MainActor @Observable @MainActor @Observable
final class AppModel { final class AppModel {
@@ -22,12 +20,16 @@ final class AppModel {
/// The one instance the app delegate and notification handler share with the views. /// The one instance the app delegate and notification handler share with the views.
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments) static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
var mode: Mode var mode: Mode {
didSet { if mode != oldValue { shareMode() } }
}
var path: [Route] = [] var path: [Route] = []
private(set) var feed: Feed? private(set) var feed: Feed?
private(set) var feedError: String? private(set) var feedError: String?
private var apnsToken: String? private var apnsToken: String?
private var refreshing = false private var refreshing = false
/// The counts the complication was last told about.
private var sharedSummary: Summary?
init(mode: Mode, feed: Feed? = nil, path: [Route] = []) { init(mode: Mode, feed: Feed? = nil, path: [Route] = []) {
self.mode = mode self.mode = mode
@@ -38,6 +40,7 @@ final class AppModel {
/// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that /// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that
/// screen, which is how the screenshots in watch/screenshots are taken. /// screen, which is how the screenshots in watch/screenshots are taken.
convenience init(launchArguments args: [String]) { convenience init(launchArguments args: [String]) {
DeviceStore.migrateToSharedGroup()
let demo = args.contains("-demo") let demo = args.contains("-demo")
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
var path: [Route] = [] var path: [Route] = []
@@ -49,6 +52,7 @@ final class AppModel {
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] } if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
} }
self.init(mode: mode, path: path) self.init(mode: mode, path: path)
shareMode()
} }
var isPaired: Bool { var isPaired: Bool {
@@ -114,8 +118,10 @@ final class AppModel {
refreshing = true refreshing = true
defer { refreshing = false } defer { refreshing = false }
do { do {
feed = try await api.feed() let feed = try await api.feed()
self.feed = feed
feedError = nil feedError = nil
share(Summary(feed))
} catch { } catch {
feedError = message(for: error) feedError = message(for: error)
} }
@@ -125,6 +131,38 @@ final class AppModel {
path = [route] 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 // MARK: - Notifications
func enableNotifications() async { func enableNotifications() async {
@@ -156,6 +194,7 @@ final class AppModel {
} }
func notificationArrived() { func notificationArrived() {
reloadComplication()
Task { await refreshFeed() } 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 // 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. // 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 Foundation
import Security import Security
@@ -12,6 +16,9 @@ struct Credentials: Equatable, Sendable {
enum DeviceStore { enum DeviceStore {
static let defaultGatewayURL = "https://wristcode.gbrown.org" 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 service = "org.gbrown.wristcode"
private static let tokenAccount = "deviceToken" private static let tokenAccount = "deviceToken"
private static let urlAccount = "gatewayURL" private static let urlAccount = "gatewayURL"
@@ -39,6 +46,23 @@ enum DeviceStore {
read(urlAccount) ?? defaultGatewayURL 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 /// 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. /// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL.
static func normalizeGatewayURL(_ input: String) -> 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? { 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[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef? var result: CFTypeRef?
@@ -75,19 +108,35 @@ enum DeviceStore {
return String(data: data, encoding: .utf8) 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) { private static func write(_ value: String, account: String) {
let data = Data(value.utf8) let data = Data(value.utf8)
let attributes: [String: Any] = [ let attributes: [String: Any] = [
kSecValueData as String: data, kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, 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 { 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) 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) { private static func delete(_ account: String) {
SecItemDelete(query(account) as CFDictionary) 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) 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 { func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") } 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) return ThreadDetail(item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking)
@@ -154,6 +158,24 @@ struct DemoFixtures {
allowCustomAnswer: true), 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( let scaffold = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac", 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), 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), projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil) 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 { func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
Message(id: id, role: role, text: text, createdAt: ago(minutes)) 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("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."), 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: [ scaffold.id: [
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."), m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"), m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
+11
View File
@@ -18,6 +18,17 @@
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <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> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
@@ -103,6 +103,7 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
return return
case let .open(route): case let .open(route):
if let route { model.open(route) } else { model.path = [] } if let route { model.open(route) } else { model.path = [] }
model.reloadComplication()
await model.refreshFeed() await model.refreshFeed()
return return
case let .respond(hostId, threadId, requestId, decision): 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) try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
} }
} }
model.reloadComplication()
await model.refreshFeed() 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 error: String?
@State private var sending = false @State private var sending = false
@State private var visible = 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 @State private var scrolledToLatest = false
var body: some View { var body: some View {
@@ -152,19 +154,29 @@ struct ThreadView: View {
Text(question.question) Text(question.question)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.padding(.top, 1) .padding(.top, 1)
if question.multiSelect {
Text("Choose any")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.top, 1)
}
VStack(spacing: 6) { VStack(spacing: 6) {
ForEach(question.options, id: \.value) { choice in if question.multiSelect {
let chosen = answers[question.id] == choice.value multiSelectOptions(request, question)
Button(choice.label) { answer(request, question.id, choice.value) } } else {
.buttonStyle(ChoiceButtonStyle( ForEach(question.options, id: \.value) { choice in
fill: chosen ? Theme.you : Theme.control, let chosen = answers[question.id] == .text(choice.value)
foreground: chosen ? .black : .white)) 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 { if question.allowCustomAnswer {
TextFieldLink(prompt: Text(question.question)) { TextFieldLink(prompt: Text(question.question)) {
Text("Other…") Text("Other…")
} onSubmit: { text in } onSubmit: { text in
answer(request, question.id, text) answer(request, question.id, .text(text))
} }
.buttonStyle(ChoiceButtonStyle()) .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 { private func statusLine(_ item: FeedItem) -> String {
let place = "\(item.hostLabel) · \(item.projectTitle)" let place = "\(item.hostLabel) · \(item.projectTitle)"
return switch item.phase { return switch item.phase {
@@ -224,13 +267,20 @@ struct ThreadView: View {
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) } 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. /// 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 answers[questionId] = value
let all = request.questions.allSatisfy { answers[$0.id] != nil } let all = request.questions.allSatisfy { answers[$0.id] != nil }
guard all else { return } guard all else { return }
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } } let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
answers = [:] answers = [:]
selections = [:]
respond(.answers(requestId: request.id, answers: payload)) respond(.answers(requestId: request.id, answers: payload))
} }
@@ -307,3 +357,10 @@ private struct QuickReplies: View {
} }
.environment(AppModel.preview()) .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> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.gbrown.wristcode</string>
</array>
</dict> </dict>
</plist> </plist>
+1
View File
@@ -40,6 +40,7 @@ struct RootView: View {
} }
.fontDesign(.rounded) .fontDesign(.rounded)
.tint(Theme.work) .tint(Theme.work)
.onOpenURL { model.open(url: $0) }
} }
} }
+67 -1
View File
@@ -69,6 +69,33 @@ private let feedItemJSON = """
#expect(request.quickOptions == nil) #expect(request.quickOptions == nil)
#expect(request.summary == "Hash the codes?") #expect(request.summary == "Hash the codes?")
#expect(request.questions.first?.allowCustomAnswer == true) #expect(request.questions.first?.allowCustomAnswer == true)
// A gateway older than multi-select leaves the flag out, which means single-select.
#expect(request.questions.first?.multiSelect == false)
}
@Test func multiSelectQuestion() throws {
let question = try decode(PendingRequest.Question.self, """
{ "id": "q1", "header": "Checks", "question": "Which checks?",
"options": [{ "label": "Unit", "value": "unit" }, { "label": "Lint", "value": "lint" }],
"allowCustomAnswer": false, "multiSelect": true }
""")
#expect(question.multiSelect)
#expect(question.options.map(\.value) == ["unit", "lint"])
}
@Test func summaryFromDocs() throws {
let summary = try decode(Summary.self, """
{ "needsYou": 1, "working": 3, "hostsOffline": 0,
"next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini" } }
""")
#expect(summary.needsYou == 1)
#expect(summary.working == 3)
#expect(summary.next?.title == "Receipt OCR")
#expect(summary.next?.route == .thread(hostId: "h", threadId: "t"))
let idle = try decode(Summary.self, #"{ "needsYou": 0, "working": 0, "hostsOffline": 2, "next": null }"#)
#expect(idle.next == nil)
#expect(idle.hostsOffline == 2)
} }
@Test func unknownEnumValuesStillDecode() throws { @Test func unknownEnumValuesStillDecode() throws {
@@ -127,11 +154,19 @@ private let feedItemJSON = """
} }
@Test func respondWithAnswers() throws { @Test func respondWithAnswers() throws {
let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": "yes"])) let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": .text("yes")]))
#expect(object["answers"] as? [String: String] == ["q1": "yes"]) #expect(object["answers"] as? [String: String] == ["q1": "yes"])
#expect(object["decision"] == nil) #expect(object["decision"] == nil)
} }
@Test func respondWithMultiSelectAnswers() throws {
let object = try encodeObject(RespondRequest.answers(
requestId: "r1", answers: ["checks": .values(["unit", "lint"]), "note": .text("after lunch")]))
let answers = try #require(object["answers"] as? [String: Any])
#expect(answers["checks"] as? [String] == ["unit", "lint"])
#expect(answers["note"] as? String == "after lunch")
}
@Test func pushRegistration() throws { @Test func pushRegistration() throws {
let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox)) let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox))
#expect(object["apnsEnvironment"] as? String == "sandbox") #expect(object["apnsEnvironment"] as? String == "sandbox")
@@ -185,6 +220,25 @@ private let feedItemJSON = """
== ProjectRef(hostId: "on", projectId: "a")) == ProjectRef(hostId: "on", projectId: "a"))
} }
@Test func summaryFromFeed() throws {
let feed = DemoFixtures.previewFeed()
let summary = Summary(feed)
#expect(summary.needsYou == feed.needsYou.count)
#expect(summary.working == feed.working.count)
#expect(summary.hostsOffline == 1)
#expect(summary.next?.threadId == feed.needsYou.first?.threadId)
#expect(Summary(Feed(needsYou: [], working: [], recent: [], hosts: [])).next == nil)
}
@Test func routeURLs() {
let thread = Route.thread(hostId: "mac mini", threadId: "t/1")
#expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1")
#expect(Route(url: thread.url) == thread)
#expect(Route(url: Route.newThread.url) == .newThread)
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
#expect(Route(url: URL(string: "https://thread/h/t")!) == nil)
}
@Test func notificationCommands() { @Test func notificationCommands() {
let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"] let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"]
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil) #expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil)
@@ -223,4 +277,16 @@ private let feedItemJSON = """
request: .decision(requestId: item.request!.id, decision: .accept)) request: .decision(requestId: item.request!.id, decision: .accept))
} }
} }
@Test func multiSelectAnswerMovesThreadToWorking() async throws {
let gateway = DemoGateway()
let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true })
let request = try #require(item.request)
let question = try #require(request.questions.first)
try await gateway.respond(
hostId: item.hostId, threadId: item.threadId,
request: .answers(requestId: request.id, answers: [question.id: .values(["unit", "lint"])]))
#expect(try await gateway.summary().working == 3)
#expect(try await gateway.feed().working.contains { $0.id == item.id })
}
} }
+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>
+38
View File
@@ -19,6 +19,9 @@ targets:
platform: watchOS platform: watchOS
sources: sources:
- WristCode - WristCode
dependencies:
# Embedded in the watch app's PlugIns folder.
- target: WristCodeWidget
info: info:
path: WristCode/Info.plist path: WristCode/Info.plist
properties: properties:
@@ -32,18 +35,53 @@ targets:
WKWatchOnly: true WKWatchOnly: true
# HTTPS only, so no export compliance documentation is needed. # HTTPS only, so no export compliance documentation is needed.
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
# wristcode://thread/<hostId>/<threadId>, which the complication opens.
CFBundleURLTypes:
- CFBundleURLName: org.gbrown.wristcode
CFBundleURLSchemes: [wristcode]
entitlements: entitlements:
path: WristCode/WristCode.entitlements path: WristCode/WristCode.entitlements
properties: properties:
# Archives are signed for development; exporting for App Store Connect re-signs with the # Archives are signed for development; exporting for App Store Connect re-signs with the
# distribution profile, which turns this into production. # distribution profile, which turns this into production.
aps-environment: development aps-environment: development
# Shared with the complication: the App Group's defaults, and its Keychain access group
# for the device token.
com.apple.security.application-groups: [group.org.gbrown.wristcode]
settings: settings:
base: base:
# The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also # The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also
# the APNs topic. # the APNs topic.
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# The complication. It shares the API client, models, Keychain store and demo fixtures with the
# app, and reads the device token through the App Group.
WristCodeWidget:
type: app-extension
platform: watchOS
sources:
- WristCodeWidget
- WristCode/API
- WristCode/Auth
- WristCode/Demo
- WristCode/Shared
- WristCode/Views/Theme.swift
info:
path: WristCodeWidget/Info.plist
properties:
CFBundleDisplayName: WristCode
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
entitlements:
path: WristCodeWidget/WristCodeWidget.entitlements
properties:
com.apple.security.application-groups: [group.org.gbrown.wristcode]
settings:
base:
# Extensions take the watch app's bundle ID as a prefix.
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp.widget
# The App Store only takes a watch-only app inside an empty iOS stub, the same pair Xcode's # The App Store only takes a watch-only app inside an empty iOS stub, the same pair Xcode's
# "Watch-only App" template creates. The stub owns the App Store Connect record # "Watch-only App" template creates. The stub owns the App Store Connect record
# (org.gbrown.wristcode) and embeds the watch app; it never installs on an iPhone. # (org.gbrown.wristcode) and embeds the watch app; it never installs on an iPhone.