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
+796 -19

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) }
}
}
+62 -5
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) {
if question.multiSelect {
multiSelectOptions(request, question)
} else {
ForEach(question.options, id: \.value) { choice in
let chosen = answers[question.id] == choice.value
Button(choice.label) { answer(request, question.id, choice.value) }
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) }
}
}
+67 -1
View File
@@ -69,6 +69,33 @@ private let feedItemJSON = """
#expect(request.quickOptions == nil)
#expect(request.summary == "Hash the codes?")
#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 {
@@ -127,11 +154,19 @@ private let feedItemJSON = """
}
@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["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 {
let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox))
#expect(object["apnsEnvironment"] as? String == "sandbox")
@@ -185,6 +220,25 @@ private let feedItemJSON = """
== 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() {
let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"]
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil)
@@ -223,4 +277,16 @@ private let feedItemJSON = """
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
sources:
- WristCode
dependencies:
# Embedded in the watch app's PlugIns folder.
- target: WristCodeWidget
info:
path: WristCode/Info.plist
properties:
@@ -32,18 +35,53 @@ targets:
WKWatchOnly: true
# HTTPS only, so no export compliance documentation is needed.
ITSAppUsesNonExemptEncryption: false
# wristcode://thread/<hostId>/<threadId>, which the complication opens.
CFBundleURLTypes:
- CFBundleURLName: org.gbrown.wristcode
CFBundleURLSchemes: [wristcode]
entitlements:
path: WristCode/WristCode.entitlements
properties:
# Archives are signed for development; exporting for App Store Connect re-signs with the
# distribution profile, which turns this into production.
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:
base:
# The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also
# the APNs topic.
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp
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
# "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.