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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ struct ThreadView: View {
|
||||
@State private var error: String?
|
||||
@State private var sending = false
|
||||
@State private var visible = false
|
||||
@State private var answers: [String: String] = [:]
|
||||
@State private var answers: [String: RespondRequest.Answer] = [:]
|
||||
/// Options ticked so far on multi-select questions, by question id.
|
||||
@State private var selections: [String: Set<String>] = [:]
|
||||
@State private var scrolledToLatest = false
|
||||
|
||||
var body: some View {
|
||||
@@ -152,19 +154,29 @@ struct ThreadView: View {
|
||||
Text(question.question)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.padding(.top, 1)
|
||||
if question.multiSelect {
|
||||
Text("Choose any")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
ForEach(question.options, id: \.value) { choice in
|
||||
let chosen = answers[question.id] == choice.value
|
||||
Button(choice.label) { answer(request, question.id, choice.value) }
|
||||
.buttonStyle(ChoiceButtonStyle(
|
||||
fill: chosen ? Theme.you : Theme.control,
|
||||
foreground: chosen ? .black : .white))
|
||||
if question.multiSelect {
|
||||
multiSelectOptions(request, question)
|
||||
} else {
|
||||
ForEach(question.options, id: \.value) { choice in
|
||||
let chosen = answers[question.id] == .text(choice.value)
|
||||
Button(choice.label) { answer(request, question.id, .text(choice.value)) }
|
||||
.buttonStyle(ChoiceButtonStyle(
|
||||
fill: chosen ? Theme.you : Theme.control,
|
||||
foreground: chosen ? .black : .white))
|
||||
}
|
||||
}
|
||||
if question.allowCustomAnswer {
|
||||
TextFieldLink(prompt: Text(question.question)) {
|
||||
Text("Other…")
|
||||
} onSubmit: { text in
|
||||
answer(request, question.id, text)
|
||||
answer(request, question.id, .text(text))
|
||||
}
|
||||
.buttonStyle(ChoiceButtonStyle())
|
||||
}
|
||||
@@ -175,6 +187,37 @@ struct ThreadView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkmark toggles, then Send. With more questions still open, the button says Done and the
|
||||
/// answers go out together once the last one is in.
|
||||
@ViewBuilder
|
||||
private func multiSelectOptions(_ request: PendingRequest, _ question: PendingRequest.Question) -> some View {
|
||||
let picked = selections[question.id, default: []]
|
||||
ForEach(question.options, id: \.value) { choice in
|
||||
let checked = picked.contains(choice.value)
|
||||
Button { toggle(question.id, choice.value) } label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: checked ? "checkmark.circle.fill" : "circle")
|
||||
Text(choice.label)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.buttonStyle(ChoiceButtonStyle(
|
||||
fill: checked ? Theme.you : Theme.control,
|
||||
foreground: checked ? .black : .white))
|
||||
.accessibilityAddTraits(checked ? .isSelected : [])
|
||||
}
|
||||
let last = request.questions.allSatisfy { $0.id == question.id || answers[$0.id] != nil }
|
||||
Button(last ? "Send" : "Done") {
|
||||
let values = question.options.map(\.value).filter(picked.contains)
|
||||
answer(request, question.id, .values(values))
|
||||
}
|
||||
.buttonStyle(PillButtonStyle(
|
||||
fill: picked.isEmpty ? Theme.control : Theme.you,
|
||||
foreground: picked.isEmpty ? Theme.done : .black))
|
||||
.disabled(picked.isEmpty)
|
||||
}
|
||||
|
||||
private func statusLine(_ item: FeedItem) -> String {
|
||||
let place = "\(item.hostLabel) · \(item.projectTitle)"
|
||||
return switch item.phase {
|
||||
@@ -224,13 +267,20 @@ struct ThreadView: View {
|
||||
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) }
|
||||
}
|
||||
|
||||
private func toggle(_ questionId: String, _ value: String) {
|
||||
if selections[questionId, default: []].remove(value) == nil {
|
||||
selections[questionId, default: []].insert(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records one answer. Once every question has one, sends them together.
|
||||
private func answer(_ request: PendingRequest, _ questionId: String, _ value: String) {
|
||||
private func answer(_ request: PendingRequest, _ questionId: String, _ value: RespondRequest.Answer) {
|
||||
answers[questionId] = value
|
||||
let all = request.questions.allSatisfy { answers[$0.id] != nil }
|
||||
guard all else { return }
|
||||
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
|
||||
answers = [:]
|
||||
selections = [:]
|
||||
respond(.answers(requestId: request.id, answers: payload))
|
||||
}
|
||||
|
||||
@@ -307,3 +357,10 @@ private struct QuickReplies: View {
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
|
||||
#Preview("Multi-select") {
|
||||
NavigationStack {
|
||||
ThreadView(hostId: "mac-mini", threadId: "t-release")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
@@ -4,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>
|
||||
@@ -40,6 +40,7 @@ struct RootView: View {
|
||||
}
|
||||
.fontDesign(.rounded)
|
||||
.tint(Theme.work)
|
||||
.onOpenURL { model.open(url: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user