From 0994fd7d5aaa1c2a7b7fe357feba34cd37452c5a Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sun, 1 Nov 2026 10:58:40 -0500 Subject: [PATCH] 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 --- watch/WristCode/API/GatewayClient.swift | 5 + watch/WristCode/API/Models.swift | 67 +++++- watch/WristCode/API/Route.swift | 42 ++++ watch/WristCode/AppModel.swift | 55 ++++- watch/WristCode/Auth/DeviceStore.swift | 55 ++++- watch/WristCode/Demo/DemoGateway.swift | 28 ++- watch/WristCode/Info.plist | 11 + .../Notifications/NotificationHandler.swift | 2 + watch/WristCode/Shared/SharedState.swift | 22 ++ watch/WristCode/Views/ThreadView.swift | 75 +++++- watch/WristCode/WristCode.entitlements | 4 + watch/WristCode/WristCodeApp.swift | 1 + watch/WristCodeTests/ModelsTests.swift | 68 +++++- watch/WristCodeWidget/Info.plist | 29 +++ watch/WristCodeWidget/WaitingView.swift | 217 ++++++++++++++++++ watch/WristCodeWidget/WaitingWidget.swift | 94 ++++++++ .../WristCodeWidget.entitlements | 10 + watch/project.yml | 38 +++ 18 files changed, 800 insertions(+), 23 deletions(-) create mode 100644 watch/WristCode/API/Route.swift create mode 100644 watch/WristCode/Shared/SharedState.swift create mode 100644 watch/WristCodeWidget/Info.plist create mode 100644 watch/WristCodeWidget/WaitingView.swift create mode 100644 watch/WristCodeWidget/WaitingWidget.swift create mode 100644 watch/WristCodeWidget/WristCodeWidget.entitlements diff --git a/watch/WristCode/API/GatewayClient.swift b/watch/WristCode/API/GatewayClient.swift index 0818e0e..150107a 100644 --- a/watch/WristCode/API/GatewayClient.swift +++ b/watch/WristCode/API/GatewayClient.swift @@ -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]) } diff --git a/watch/WristCode/API/Models.swift b/watch/WristCode/API/Models.swift index 98becd3..4c97c6b 100644 --- a/watch/WristCode/API/Models.swift +++ b/watch/WristCode/API/Models.swift @@ -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 } diff --git a/watch/WristCode/API/Route.swift b/watch/WristCode/API/Route.swift new file mode 100644 index 0000000..e73da50 --- /dev/null +++ b/watch/WristCode/API/Route.swift @@ -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//` 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 + } + } +} diff --git a/watch/WristCode/AppModel.swift b/watch/WristCode/AppModel.swift index 6537fb1..fff5e79 100644 --- a/watch/WristCode/AppModel.swift +++ b/watch/WristCode/AppModel.swift @@ -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 /` 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() } } } diff --git a/watch/WristCode/Auth/DeviceStore.swift b/watch/WristCode/Auth/DeviceStore.swift index 271e9cc..3f748d1 100644 --- a/watch/WristCode/Auth/DeviceStore.swift +++ b/watch/WristCode/Auth/DeviceStore.swift @@ -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) } diff --git a/watch/WristCode/Demo/DemoGateway.swift b/watch/WristCode/Demo/DemoGateway.swift index 2ab5bff..043c902 100644 --- a/watch/WristCode/Demo/DemoGateway.swift +++ b/watch/WristCode/Demo/DemoGateway.swift @@ -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"), diff --git a/watch/WristCode/Info.plist b/watch/WristCode/Info.plist index cf19714..540e621 100644 --- a/watch/WristCode/Info.plist +++ b/watch/WristCode/Info.plist @@ -18,6 +18,17 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + org.gbrown.wristcode + CFBundleURLSchemes + + wristcode + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption diff --git a/watch/WristCode/Notifications/NotificationHandler.swift b/watch/WristCode/Notifications/NotificationHandler.swift index 2886dd2..341091b 100644 --- a/watch/WristCode/Notifications/NotificationHandler.swift +++ b/watch/WristCode/Notifications/NotificationHandler.swift @@ -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() } diff --git a/watch/WristCode/Shared/SharedState.swift b/watch/WristCode/Shared/SharedState.swift new file mode 100644 index 0000000..9fbfc95 --- /dev/null +++ b/watch/WristCode/Shared/SharedState.swift @@ -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) } + } +} diff --git a/watch/WristCode/Views/ThreadView.swift b/watch/WristCode/Views/ThreadView.swift index aa67ea6..8938858 100644 --- a/watch/WristCode/Views/ThreadView.swift +++ b/watch/WristCode/Views/ThreadView.swift @@ -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] = [:] @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()) +} diff --git a/watch/WristCode/WristCode.entitlements b/watch/WristCode/WristCode.entitlements index 903def2..469b974 100644 --- a/watch/WristCode/WristCode.entitlements +++ b/watch/WristCode/WristCode.entitlements @@ -4,5 +4,9 @@ aps-environment development + com.apple.security.application-groups + + group.org.gbrown.wristcode + diff --git a/watch/WristCode/WristCodeApp.swift b/watch/WristCode/WristCodeApp.swift index 1e5874c..9cdd53f 100644 --- a/watch/WristCode/WristCodeApp.swift +++ b/watch/WristCode/WristCodeApp.swift @@ -40,6 +40,7 @@ struct RootView: View { } .fontDesign(.rounded) .tint(Theme.work) + .onOpenURL { model.open(url: $0) } } } diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift index b773ef3..98ad885 100644 --- a/watch/WristCodeTests/ModelsTests.swift +++ b/watch/WristCodeTests/ModelsTests.swift @@ -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 }) + } } diff --git a/watch/WristCodeWidget/Info.plist b/watch/WristCodeWidget/Info.plist new file mode 100644 index 0000000..5b0aaf1 --- /dev/null +++ b/watch/WristCodeWidget/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + WristCode + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/watch/WristCodeWidget/WaitingView.swift b/watch/WristCodeWidget/WaitingView.swift new file mode 100644 index 0000000..e77ff50 --- /dev/null +++ b/watch/WristCodeWidget/WaitingView.swift @@ -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) +} diff --git a/watch/WristCodeWidget/WaitingWidget.swift b/watch/WristCodeWidget/WaitingWidget.swift new file mode 100644 index 0000000..601b3a7 --- /dev/null +++ b/watch/WristCodeWidget/WaitingWidget.swift @@ -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) -> 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)) + } + } +} diff --git a/watch/WristCodeWidget/WristCodeWidget.entitlements b/watch/WristCodeWidget/WristCodeWidget.entitlements new file mode 100644 index 0000000..bbf03d5 --- /dev/null +++ b/watch/WristCodeWidget/WristCodeWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.gbrown.wristcode + + + diff --git a/watch/project.yml b/watch/project.yml index c0509c4..08666d9 100644 --- a/watch/project.yml +++ b/watch/project.yml @@ -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//, 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.