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:
1 parent
d4d67295c6
commit
0994fd7d5a
18 files changed
+800
-23
No files matched your search
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user