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.
|
/// serves fixtures, so every screen works without a live gateway.
|
||||||
protocol GatewayAPI: Sendable {
|
protocol GatewayAPI: Sendable {
|
||||||
func feed() async throws -> Feed
|
func feed() async throws -> Feed
|
||||||
|
func summary() async throws -> Summary
|
||||||
func thread(hostId: String, threadId: String) async throws -> ThreadDetail
|
func thread(hostId: String, threadId: String) async throws -> ThreadDetail
|
||||||
func sendMessage(hostId: String, threadId: String, text: String) async throws
|
func sendMessage(hostId: String, threadId: String, text: String) async throws
|
||||||
func respond(hostId: String, threadId: String, request: RespondRequest) async throws
|
func respond(hostId: String, threadId: String, request: RespondRequest) async throws
|
||||||
@@ -68,6 +69,10 @@ struct GatewayClient: GatewayAPI {
|
|||||||
try await call("GET", ["v1", "feed"])
|
try await call("GET", ["v1", "feed"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func summary() async throws -> Summary {
|
||||||
|
try await call("GET", ["v1", "summary"])
|
||||||
|
}
|
||||||
|
|
||||||
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
|
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
|
||||||
try await call("GET", ["v1", "threads", hostId, threadId])
|
try await call("GET", ["v1", "threads", hostId, threadId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
|
|||||||
var question: String
|
var question: String
|
||||||
var options: [Choice]
|
var options: [Choice]
|
||||||
var allowCustomAnswer: Bool
|
var allowCustomAnswer: Bool
|
||||||
|
/// When true, the answer is an array of option values.
|
||||||
|
var multiSelect = false
|
||||||
}
|
}
|
||||||
|
|
||||||
var id: String
|
var id: String
|
||||||
@@ -113,6 +115,24 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension PendingRequest.Question {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, header, question, options, allowCustomAnswer, multiSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `multiSelect` is newer than the rest, so a gateway that leaves it out means single-select.
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.init(
|
||||||
|
id: try container.decode(String.self, forKey: .id),
|
||||||
|
header: try container.decode(String.self, forKey: .header),
|
||||||
|
question: try container.decode(String.self, forKey: .question),
|
||||||
|
options: try container.decode([Choice].self, forKey: .options),
|
||||||
|
allowCustomAnswer: try container.decode(Bool.self, forKey: .allowCustomAnswer),
|
||||||
|
multiSelect: try container.decodeIfPresent(Bool.self, forKey: .multiSelect) ?? false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct FeedItem: Codable, Hashable, Sendable, Identifiable {
|
struct FeedItem: Codable, Hashable, Sendable, Identifiable {
|
||||||
var hostId: String
|
var hostId: String
|
||||||
var hostLabel: String
|
var hostLabel: String
|
||||||
@@ -136,6 +156,37 @@ struct Feed: Codable, Hashable, Sendable {
|
|||||||
var hosts: [HostSummary]
|
var hosts: [HostSummary]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /v1/summary`: the counts behind the complication.
|
||||||
|
struct Summary: Codable, Hashable, Sendable {
|
||||||
|
struct Next: Codable, Hashable, Sendable {
|
||||||
|
var hostId: String
|
||||||
|
var threadId: String
|
||||||
|
var title: String
|
||||||
|
var hostLabel: String
|
||||||
|
|
||||||
|
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var needsYou: Int
|
||||||
|
var working: Int
|
||||||
|
/// The thread that has waited longest for you.
|
||||||
|
var next: Next?
|
||||||
|
var hostsOffline: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Summary {
|
||||||
|
/// The same counts worked out from a feed. The feed lists needs-you oldest request first, so its
|
||||||
|
/// first item is the one that has waited longest.
|
||||||
|
init(_ feed: Feed) {
|
||||||
|
let next = feed.needsYou.first
|
||||||
|
self.init(
|
||||||
|
needsYou: feed.needsYou.count,
|
||||||
|
working: feed.working.count,
|
||||||
|
next: next.map { Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel) },
|
||||||
|
hostsOffline: feed.hosts.filter { $0.status != .online }.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Message: Codable, Hashable, Sendable, Identifiable {
|
struct Message: Codable, Hashable, Sendable, Identifiable {
|
||||||
struct Role: RawRepresentable, Codable, Hashable, Sendable {
|
struct Role: RawRepresentable, Codable, Hashable, Sendable {
|
||||||
let rawValue: String
|
let rawValue: String
|
||||||
@@ -203,8 +254,22 @@ struct SendMessageRequest: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum RespondRequest: Encodable, Sendable {
|
enum RespondRequest: Encodable, Sendable {
|
||||||
|
/// One question's answer: an option value or typed text, or option values for a multi-select question.
|
||||||
|
enum Answer: Encodable, Hashable, Sendable {
|
||||||
|
case text(String)
|
||||||
|
case values([String])
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case let .text(text): try container.encode(text)
|
||||||
|
case let .values(values): try container.encode(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case decision(requestId: String, decision: ApprovalDecision)
|
case decision(requestId: String, decision: ApprovalDecision)
|
||||||
case answers(requestId: String, answers: [String: String])
|
case answers(requestId: String, answers: [String: Answer])
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey { case requestId, decision, answers }
|
private enum CodingKeys: String, CodingKey { case requestId, decision, answers }
|
||||||
|
|
||||||
|
|||||||
@@ -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 Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import WatchKit
|
import WatchKit
|
||||||
|
import WidgetKit
|
||||||
enum Route: Hashable, Sendable {
|
|
||||||
case thread(hostId: String, threadId: String)
|
|
||||||
case newThread
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor @Observable
|
@MainActor @Observable
|
||||||
final class AppModel {
|
final class AppModel {
|
||||||
@@ -22,12 +20,16 @@ final class AppModel {
|
|||||||
/// The one instance the app delegate and notification handler share with the views.
|
/// The one instance the app delegate and notification handler share with the views.
|
||||||
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
|
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
|
||||||
|
|
||||||
var mode: Mode
|
var mode: Mode {
|
||||||
|
didSet { if mode != oldValue { shareMode() } }
|
||||||
|
}
|
||||||
var path: [Route] = []
|
var path: [Route] = []
|
||||||
private(set) var feed: Feed?
|
private(set) var feed: Feed?
|
||||||
private(set) var feedError: String?
|
private(set) var feedError: String?
|
||||||
private var apnsToken: String?
|
private var apnsToken: String?
|
||||||
private var refreshing = false
|
private var refreshing = false
|
||||||
|
/// The counts the complication was last told about.
|
||||||
|
private var sharedSummary: Summary?
|
||||||
|
|
||||||
init(mode: Mode, feed: Feed? = nil, path: [Route] = []) {
|
init(mode: Mode, feed: Feed? = nil, path: [Route] = []) {
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -38,6 +40,7 @@ final class AppModel {
|
|||||||
/// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that
|
/// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that
|
||||||
/// screen, which is how the screenshots in watch/screenshots are taken.
|
/// screen, which is how the screenshots in watch/screenshots are taken.
|
||||||
convenience init(launchArguments args: [String]) {
|
convenience init(launchArguments args: [String]) {
|
||||||
|
DeviceStore.migrateToSharedGroup()
|
||||||
let demo = args.contains("-demo")
|
let demo = args.contains("-demo")
|
||||||
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
|
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
|
||||||
var path: [Route] = []
|
var path: [Route] = []
|
||||||
@@ -49,6 +52,7 @@ final class AppModel {
|
|||||||
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
|
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
|
||||||
}
|
}
|
||||||
self.init(mode: mode, path: path)
|
self.init(mode: mode, path: path)
|
||||||
|
shareMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPaired: Bool {
|
var isPaired: Bool {
|
||||||
@@ -114,8 +118,10 @@ final class AppModel {
|
|||||||
refreshing = true
|
refreshing = true
|
||||||
defer { refreshing = false }
|
defer { refreshing = false }
|
||||||
do {
|
do {
|
||||||
feed = try await api.feed()
|
let feed = try await api.feed()
|
||||||
|
self.feed = feed
|
||||||
feedError = nil
|
feedError = nil
|
||||||
|
share(Summary(feed))
|
||||||
} catch {
|
} catch {
|
||||||
feedError = message(for: error)
|
feedError = message(for: error)
|
||||||
}
|
}
|
||||||
@@ -125,6 +131,38 @@ final class AppModel {
|
|||||||
path = [route]
|
path = [route]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens a `wristcode://` link from the complication.
|
||||||
|
func open(url: URL) {
|
||||||
|
guard mode != .unpaired, let route = Route(url: url) else { return }
|
||||||
|
open(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Complication
|
||||||
|
|
||||||
|
/// Tells the complication about the mode. Counts from another mode, or from before signing
|
||||||
|
/// out, are forgotten.
|
||||||
|
private func shareMode() {
|
||||||
|
let demo = mode == .demo
|
||||||
|
if SharedState.demo != demo || mode == .unpaired { SharedState.lastSummary = nil }
|
||||||
|
SharedState.demo = demo
|
||||||
|
sharedSummary = nil
|
||||||
|
reloadComplication()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands the complication fresh counts. The feed refreshes every 10 s while on screen, so the
|
||||||
|
/// timeline only reloads when the counts or the next thread change.
|
||||||
|
private func share(_ summary: Summary) {
|
||||||
|
guard summary != sharedSummary else { return }
|
||||||
|
sharedSummary = summary
|
||||||
|
SharedState.lastSummary = summary
|
||||||
|
reloadComplication()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the complication to fetch the summary again, as after a push or a notification action.
|
||||||
|
func reloadComplication() {
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
|
|
||||||
func enableNotifications() async {
|
func enableNotifications() async {
|
||||||
@@ -156,6 +194,7 @@ final class AppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func notificationArrived() {
|
func notificationArrived() {
|
||||||
|
reloadComplication()
|
||||||
Task { await refreshFeed() }
|
Task { await refreshFeed() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// The device token and gateway URL, kept in the Keychain. They are readable after first unlock, so
|
// The device token and gateway URL, kept in the Keychain. They are readable after first unlock, so
|
||||||
// a notification action handled in the background can still call the gateway.
|
// a notification action handled in the background can still call the gateway.
|
||||||
|
//
|
||||||
|
// The items live in the App Group's Keychain access group, so the complication can read them too.
|
||||||
|
// Builds before the complication kept them in the app's own access group; `migrateToSharedGroup()`
|
||||||
|
// moves them on launch, so a paired watch stays paired.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
@@ -12,6 +16,9 @@ struct Credentials: Equatable, Sendable {
|
|||||||
enum DeviceStore {
|
enum DeviceStore {
|
||||||
static let defaultGatewayURL = "https://wristcode.gbrown.org"
|
static let defaultGatewayURL = "https://wristcode.gbrown.org"
|
||||||
|
|
||||||
|
/// The App Group the app and the complication share. It doubles as the Keychain access group.
|
||||||
|
static let appGroup = "group.org.gbrown.wristcode"
|
||||||
|
|
||||||
private static let service = "org.gbrown.wristcode"
|
private static let service = "org.gbrown.wristcode"
|
||||||
private static let tokenAccount = "deviceToken"
|
private static let tokenAccount = "deviceToken"
|
||||||
private static let urlAccount = "gatewayURL"
|
private static let urlAccount = "gatewayURL"
|
||||||
@@ -39,6 +46,23 @@ enum DeviceStore {
|
|||||||
read(urlAccount) ?? defaultGatewayURL
|
read(urlAccount) ?? defaultGatewayURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moves items an older build saved in the app's own access group into the shared one. Does
|
||||||
|
/// nothing once they have moved. If the move fails the old items stay, and `load()` still finds
|
||||||
|
/// them, so the app keeps working and only the complication goes without.
|
||||||
|
static func migrateToSharedGroup() {
|
||||||
|
for account in [tokenAccount, urlAccount] where readShared(account) == nil {
|
||||||
|
guard let legacy = readAnyGroup(account), legacy.group != appGroup else { continue }
|
||||||
|
var item = query(account)
|
||||||
|
item[kSecAttrAccessGroup as String] = appGroup
|
||||||
|
item[kSecValueData as String] = Data(legacy.value.utf8)
|
||||||
|
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { continue }
|
||||||
|
var old = query(account)
|
||||||
|
old[kSecAttrAccessGroup as String] = legacy.group
|
||||||
|
SecItemDelete(old as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Turns what someone typed into a gateway base URL: trims it, adds `https://` when there is
|
/// Turns what someone typed into a gateway base URL: trims it, adds `https://` when there is
|
||||||
/// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL.
|
/// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL.
|
||||||
static func normalizeGatewayURL(_ input: String) -> URL? {
|
static func normalizeGatewayURL(_ input: String) -> URL? {
|
||||||
@@ -64,8 +88,17 @@ enum DeviceStore {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func sharedQuery(_ account: String) -> [String: Any] {
|
||||||
|
query(account).merging([kSecAttrAccessGroup as String: appGroup]) { _, new in new }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared item, or failing that one an older build left in the app's own group.
|
||||||
private static func read(_ account: String) -> String? {
|
private static func read(_ account: String) -> String? {
|
||||||
var query = query(account)
|
readShared(account) ?? readAnyGroup(account)?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readShared(_ account: String) -> String? {
|
||||||
|
var query = sharedQuery(account)
|
||||||
query[kSecReturnData as String] = true
|
query[kSecReturnData as String] = true
|
||||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
var result: CFTypeRef?
|
var result: CFTypeRef?
|
||||||
@@ -75,19 +108,35 @@ enum DeviceStore {
|
|||||||
return String(data: data, encoding: .utf8)
|
return String(data: data, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An item in any access group this process can read, with the group it is in.
|
||||||
|
private static func readAnyGroup(_ account: String) -> (value: String, group: String)? {
|
||||||
|
var query = query(account)
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecReturnAttributes as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||||
|
let attributes = result as? [String: Any],
|
||||||
|
let data = attributes[kSecValueData as String] as? Data,
|
||||||
|
let value = String(data: data, encoding: .utf8)
|
||||||
|
else { return nil }
|
||||||
|
return (value, attributes[kSecAttrAccessGroup as String] as? String ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
private static func write(_ value: String, account: String) {
|
private static func write(_ value: String, account: String) {
|
||||||
let data = Data(value.utf8)
|
let data = Data(value.utf8)
|
||||||
let attributes: [String: Any] = [
|
let attributes: [String: Any] = [
|
||||||
kSecValueData as String: data,
|
kSecValueData as String: data,
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||||
]
|
]
|
||||||
let status = SecItemUpdate(query(account) as CFDictionary, attributes as CFDictionary)
|
let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary)
|
||||||
if status == errSecItemNotFound {
|
if status == errSecItemNotFound {
|
||||||
let item = query(account).merging(attributes) { _, new in new }
|
let item = sharedQuery(account).merging(attributes) { _, new in new }
|
||||||
SecItemAdd(item as CFDictionary, nil)
|
SecItemAdd(item as CFDictionary, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes the item from every access group, so a leftover from an older build goes too.
|
||||||
private static func delete(_ account: String) {
|
private static func delete(_ account: String) {
|
||||||
SecItemDelete(query(account) as CFDictionary)
|
SecItemDelete(query(account) as CFDictionary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ actor DemoGateway: GatewayAPI {
|
|||||||
return Feed(needsYou: needsYou, working: working, recent: Array(recent.prefix(10)), hosts: hosts)
|
return Feed(needsYou: needsYou, working: working, recent: Array(recent.prefix(10)), hosts: hosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func summary() async throws -> Summary {
|
||||||
|
Summary(try await feed())
|
||||||
|
}
|
||||||
|
|
||||||
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
|
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
|
||||||
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
|
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
|
||||||
return ThreadDetail(item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking)
|
return ThreadDetail(item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking)
|
||||||
@@ -154,6 +158,24 @@ struct DemoFixtures {
|
|||||||
allowCustomAnswer: true),
|
allowCustomAnswer: true),
|
||||||
]))
|
]))
|
||||||
|
|
||||||
|
let release = FeedItem(
|
||||||
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-release", projectId: "p-wc-mac",
|
||||||
|
projectTitle: "wristcode", title: "Release checklist", phase: .waitingForInput,
|
||||||
|
updatedAt: ago(6), lastMessage: "The build is ready. Which checks should run first?",
|
||||||
|
request: PendingRequest(
|
||||||
|
id: "r-release", kind: "user_input", prompt: nil, reason: nil, options: [],
|
||||||
|
questions: [
|
||||||
|
.init(
|
||||||
|
id: "checks", header: "Checks", question: "Which checks should run before upload?",
|
||||||
|
options: [
|
||||||
|
.init(label: "Unit tests", value: "unit"),
|
||||||
|
.init(label: "UI tests", value: "ui"),
|
||||||
|
.init(label: "Lint", value: "lint"),
|
||||||
|
.init(label: "Archive", value: "archive"),
|
||||||
|
],
|
||||||
|
allowCustomAnswer: true, multiSelect: true),
|
||||||
|
]))
|
||||||
|
|
||||||
let scaffold = FeedItem(
|
let scaffold = FeedItem(
|
||||||
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac",
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac",
|
||||||
projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5),
|
projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5),
|
||||||
@@ -174,7 +196,7 @@ struct DemoFixtures {
|
|||||||
projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
|
projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
|
||||||
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil)
|
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil)
|
||||||
|
|
||||||
items = [approval, question, scaffold, containerfile, nextjs, cdn]
|
items = [approval, question, release, scaffold, containerfile, nextjs, cdn]
|
||||||
|
|
||||||
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
|
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
|
||||||
Message(id: id, role: role, text: text, createdAt: ago(minutes))
|
Message(id: id, role: role, text: text, createdAt: ago(minutes))
|
||||||
@@ -190,6 +212,10 @@ struct DemoFixtures {
|
|||||||
m("b1", .user, 10, "Add pairing codes to the gateway."),
|
m("b1", .user, 10, "Add pairing codes to the gateway."),
|
||||||
m("b2", .assistant, 1, "Two ways to store pairing codes. Hashing is safer, plain is simpler to debug."),
|
m("b2", .assistant, 1, "Two ways to store pairing codes. Hashing is safer, plain is simpler to debug."),
|
||||||
],
|
],
|
||||||
|
release.id: [
|
||||||
|
m("r1", .user, 25, "Get the watch app ready for TestFlight."),
|
||||||
|
m("r2", .assistant, 6, "The build is ready. Which checks should run first?"),
|
||||||
|
],
|
||||||
scaffold.id: [
|
scaffold.id: [
|
||||||
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
|
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
|
||||||
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
|
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
|
||||||
|
|||||||
@@ -18,6 +18,17 @@
|
|||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>org.gbrown.wristcode</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>wristcode</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
return
|
return
|
||||||
case let .open(route):
|
case let .open(route):
|
||||||
if let route { model.open(route) } else { model.path = [] }
|
if let route { model.open(route) } else { model.path = [] }
|
||||||
|
model.reloadComplication()
|
||||||
await model.refreshFeed()
|
await model.refreshFeed()
|
||||||
return
|
return
|
||||||
case let .respond(hostId, threadId, requestId, decision):
|
case let .respond(hostId, threadId, requestId, decision):
|
||||||
@@ -118,6 +119,7 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
|
try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
model.reloadComplication()
|
||||||
await model.refreshFeed()
|
await model.refreshFeed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 error: String?
|
||||||
@State private var sending = false
|
@State private var sending = false
|
||||||
@State private var visible = false
|
@State private var visible = false
|
||||||
@State private var answers: [String: String] = [:]
|
@State private var answers: [String: RespondRequest.Answer] = [:]
|
||||||
|
/// Options ticked so far on multi-select questions, by question id.
|
||||||
|
@State private var selections: [String: Set<String>] = [:]
|
||||||
@State private var scrolledToLatest = false
|
@State private var scrolledToLatest = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -152,19 +154,29 @@ struct ThreadView: View {
|
|||||||
Text(question.question)
|
Text(question.question)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.padding(.top, 1)
|
.padding(.top, 1)
|
||||||
|
if question.multiSelect {
|
||||||
|
Text("Choose any")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
ForEach(question.options, id: \.value) { choice in
|
if question.multiSelect {
|
||||||
let chosen = answers[question.id] == choice.value
|
multiSelectOptions(request, question)
|
||||||
Button(choice.label) { answer(request, question.id, choice.value) }
|
} else {
|
||||||
.buttonStyle(ChoiceButtonStyle(
|
ForEach(question.options, id: \.value) { choice in
|
||||||
fill: chosen ? Theme.you : Theme.control,
|
let chosen = answers[question.id] == .text(choice.value)
|
||||||
foreground: chosen ? .black : .white))
|
Button(choice.label) { answer(request, question.id, .text(choice.value)) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(
|
||||||
|
fill: chosen ? Theme.you : Theme.control,
|
||||||
|
foreground: chosen ? .black : .white))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if question.allowCustomAnswer {
|
if question.allowCustomAnswer {
|
||||||
TextFieldLink(prompt: Text(question.question)) {
|
TextFieldLink(prompt: Text(question.question)) {
|
||||||
Text("Other…")
|
Text("Other…")
|
||||||
} onSubmit: { text in
|
} onSubmit: { text in
|
||||||
answer(request, question.id, text)
|
answer(request, question.id, .text(text))
|
||||||
}
|
}
|
||||||
.buttonStyle(ChoiceButtonStyle())
|
.buttonStyle(ChoiceButtonStyle())
|
||||||
}
|
}
|
||||||
@@ -175,6 +187,37 @@ struct ThreadView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checkmark toggles, then Send. With more questions still open, the button says Done and the
|
||||||
|
/// answers go out together once the last one is in.
|
||||||
|
@ViewBuilder
|
||||||
|
private func multiSelectOptions(_ request: PendingRequest, _ question: PendingRequest.Question) -> some View {
|
||||||
|
let picked = selections[question.id, default: []]
|
||||||
|
ForEach(question.options, id: \.value) { choice in
|
||||||
|
let checked = picked.contains(choice.value)
|
||||||
|
Button { toggle(question.id, choice.value) } label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: checked ? "checkmark.circle.fill" : "circle")
|
||||||
|
Text(choice.label)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(ChoiceButtonStyle(
|
||||||
|
fill: checked ? Theme.you : Theme.control,
|
||||||
|
foreground: checked ? .black : .white))
|
||||||
|
.accessibilityAddTraits(checked ? .isSelected : [])
|
||||||
|
}
|
||||||
|
let last = request.questions.allSatisfy { $0.id == question.id || answers[$0.id] != nil }
|
||||||
|
Button(last ? "Send" : "Done") {
|
||||||
|
let values = question.options.map(\.value).filter(picked.contains)
|
||||||
|
answer(request, question.id, .values(values))
|
||||||
|
}
|
||||||
|
.buttonStyle(PillButtonStyle(
|
||||||
|
fill: picked.isEmpty ? Theme.control : Theme.you,
|
||||||
|
foreground: picked.isEmpty ? Theme.done : .black))
|
||||||
|
.disabled(picked.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
private func statusLine(_ item: FeedItem) -> String {
|
private func statusLine(_ item: FeedItem) -> String {
|
||||||
let place = "\(item.hostLabel) · \(item.projectTitle)"
|
let place = "\(item.hostLabel) · \(item.projectTitle)"
|
||||||
return switch item.phase {
|
return switch item.phase {
|
||||||
@@ -224,13 +267,20 @@ struct ThreadView: View {
|
|||||||
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) }
|
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func toggle(_ questionId: String, _ value: String) {
|
||||||
|
if selections[questionId, default: []].remove(value) == nil {
|
||||||
|
selections[questionId, default: []].insert(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Records one answer. Once every question has one, sends them together.
|
/// Records one answer. Once every question has one, sends them together.
|
||||||
private func answer(_ request: PendingRequest, _ questionId: String, _ value: String) {
|
private func answer(_ request: PendingRequest, _ questionId: String, _ value: RespondRequest.Answer) {
|
||||||
answers[questionId] = value
|
answers[questionId] = value
|
||||||
let all = request.questions.allSatisfy { answers[$0.id] != nil }
|
let all = request.questions.allSatisfy { answers[$0.id] != nil }
|
||||||
guard all else { return }
|
guard all else { return }
|
||||||
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
|
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
|
||||||
answers = [:]
|
answers = [:]
|
||||||
|
selections = [:]
|
||||||
respond(.answers(requestId: request.id, answers: payload))
|
respond(.answers(requestId: request.id, answers: payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,3 +357,10 @@ private struct QuickReplies: View {
|
|||||||
}
|
}
|
||||||
.environment(AppModel.preview())
|
.environment(AppModel.preview())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("Multi-select") {
|
||||||
|
NavigationStack {
|
||||||
|
ThreadView(hostId: "mac-mini", threadId: "t-release")
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
@@ -4,5 +4,9 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.org.gbrown.wristcode</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -40,6 +40,7 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
.fontDesign(.rounded)
|
.fontDesign(.rounded)
|
||||||
.tint(Theme.work)
|
.tint(Theme.work)
|
||||||
|
.onOpenURL { model.open(url: $0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,33 @@ private let feedItemJSON = """
|
|||||||
#expect(request.quickOptions == nil)
|
#expect(request.quickOptions == nil)
|
||||||
#expect(request.summary == "Hash the codes?")
|
#expect(request.summary == "Hash the codes?")
|
||||||
#expect(request.questions.first?.allowCustomAnswer == true)
|
#expect(request.questions.first?.allowCustomAnswer == true)
|
||||||
|
// A gateway older than multi-select leaves the flag out, which means single-select.
|
||||||
|
#expect(request.questions.first?.multiSelect == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func multiSelectQuestion() throws {
|
||||||
|
let question = try decode(PendingRequest.Question.self, """
|
||||||
|
{ "id": "q1", "header": "Checks", "question": "Which checks?",
|
||||||
|
"options": [{ "label": "Unit", "value": "unit" }, { "label": "Lint", "value": "lint" }],
|
||||||
|
"allowCustomAnswer": false, "multiSelect": true }
|
||||||
|
""")
|
||||||
|
#expect(question.multiSelect)
|
||||||
|
#expect(question.options.map(\.value) == ["unit", "lint"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func summaryFromDocs() throws {
|
||||||
|
let summary = try decode(Summary.self, """
|
||||||
|
{ "needsYou": 1, "working": 3, "hostsOffline": 0,
|
||||||
|
"next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini" } }
|
||||||
|
""")
|
||||||
|
#expect(summary.needsYou == 1)
|
||||||
|
#expect(summary.working == 3)
|
||||||
|
#expect(summary.next?.title == "Receipt OCR")
|
||||||
|
#expect(summary.next?.route == .thread(hostId: "h", threadId: "t"))
|
||||||
|
|
||||||
|
let idle = try decode(Summary.self, #"{ "needsYou": 0, "working": 0, "hostsOffline": 2, "next": null }"#)
|
||||||
|
#expect(idle.next == nil)
|
||||||
|
#expect(idle.hostsOffline == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func unknownEnumValuesStillDecode() throws {
|
@Test func unknownEnumValuesStillDecode() throws {
|
||||||
@@ -127,11 +154,19 @@ private let feedItemJSON = """
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func respondWithAnswers() throws {
|
@Test func respondWithAnswers() throws {
|
||||||
let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": "yes"]))
|
let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": .text("yes")]))
|
||||||
#expect(object["answers"] as? [String: String] == ["q1": "yes"])
|
#expect(object["answers"] as? [String: String] == ["q1": "yes"])
|
||||||
#expect(object["decision"] == nil)
|
#expect(object["decision"] == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func respondWithMultiSelectAnswers() throws {
|
||||||
|
let object = try encodeObject(RespondRequest.answers(
|
||||||
|
requestId: "r1", answers: ["checks": .values(["unit", "lint"]), "note": .text("after lunch")]))
|
||||||
|
let answers = try #require(object["answers"] as? [String: Any])
|
||||||
|
#expect(answers["checks"] as? [String] == ["unit", "lint"])
|
||||||
|
#expect(answers["note"] as? String == "after lunch")
|
||||||
|
}
|
||||||
|
|
||||||
@Test func pushRegistration() throws {
|
@Test func pushRegistration() throws {
|
||||||
let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox))
|
let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox))
|
||||||
#expect(object["apnsEnvironment"] as? String == "sandbox")
|
#expect(object["apnsEnvironment"] as? String == "sandbox")
|
||||||
@@ -185,6 +220,25 @@ private let feedItemJSON = """
|
|||||||
== ProjectRef(hostId: "on", projectId: "a"))
|
== ProjectRef(hostId: "on", projectId: "a"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func summaryFromFeed() throws {
|
||||||
|
let feed = DemoFixtures.previewFeed()
|
||||||
|
let summary = Summary(feed)
|
||||||
|
#expect(summary.needsYou == feed.needsYou.count)
|
||||||
|
#expect(summary.working == feed.working.count)
|
||||||
|
#expect(summary.hostsOffline == 1)
|
||||||
|
#expect(summary.next?.threadId == feed.needsYou.first?.threadId)
|
||||||
|
#expect(Summary(Feed(needsYou: [], working: [], recent: [], hosts: [])).next == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func routeURLs() {
|
||||||
|
let thread = Route.thread(hostId: "mac mini", threadId: "t/1")
|
||||||
|
#expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1")
|
||||||
|
#expect(Route(url: thread.url) == thread)
|
||||||
|
#expect(Route(url: Route.newThread.url) == .newThread)
|
||||||
|
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
|
||||||
|
#expect(Route(url: URL(string: "https://thread/h/t")!) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func notificationCommands() {
|
@Test func notificationCommands() {
|
||||||
let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"]
|
let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"]
|
||||||
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil)
|
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil)
|
||||||
@@ -223,4 +277,16 @@ private let feedItemJSON = """
|
|||||||
request: .decision(requestId: item.request!.id, decision: .accept))
|
request: .decision(requestId: item.request!.id, decision: .accept))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func multiSelectAnswerMovesThreadToWorking() async throws {
|
||||||
|
let gateway = DemoGateway()
|
||||||
|
let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true })
|
||||||
|
let request = try #require(item.request)
|
||||||
|
let question = try #require(request.questions.first)
|
||||||
|
try await gateway.respond(
|
||||||
|
hostId: item.hostId, threadId: item.threadId,
|
||||||
|
request: .answers(requestId: request.id, answers: [question.id: .values(["unit", "lint"])]))
|
||||||
|
#expect(try await gateway.summary().working == 3)
|
||||||
|
#expect(try await gateway.feed().working.contains { $0.id == item.id })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -19,6 +19,9 @@ targets:
|
|||||||
platform: watchOS
|
platform: watchOS
|
||||||
sources:
|
sources:
|
||||||
- WristCode
|
- WristCode
|
||||||
|
dependencies:
|
||||||
|
# Embedded in the watch app's PlugIns folder.
|
||||||
|
- target: WristCodeWidget
|
||||||
info:
|
info:
|
||||||
path: WristCode/Info.plist
|
path: WristCode/Info.plist
|
||||||
properties:
|
properties:
|
||||||
@@ -32,18 +35,53 @@ targets:
|
|||||||
WKWatchOnly: true
|
WKWatchOnly: true
|
||||||
# HTTPS only, so no export compliance documentation is needed.
|
# HTTPS only, so no export compliance documentation is needed.
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
|
# wristcode://thread/<hostId>/<threadId>, which the complication opens.
|
||||||
|
CFBundleURLTypes:
|
||||||
|
- CFBundleURLName: org.gbrown.wristcode
|
||||||
|
CFBundleURLSchemes: [wristcode]
|
||||||
entitlements:
|
entitlements:
|
||||||
path: WristCode/WristCode.entitlements
|
path: WristCode/WristCode.entitlements
|
||||||
properties:
|
properties:
|
||||||
# Archives are signed for development; exporting for App Store Connect re-signs with the
|
# Archives are signed for development; exporting for App Store Connect re-signs with the
|
||||||
# distribution profile, which turns this into production.
|
# distribution profile, which turns this into production.
|
||||||
aps-environment: development
|
aps-environment: development
|
||||||
|
# Shared with the complication: the App Group's defaults, and its Keychain access group
|
||||||
|
# for the device token.
|
||||||
|
com.apple.security.application-groups: [group.org.gbrown.wristcode]
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
# The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also
|
# The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also
|
||||||
# the APNs topic.
|
# the APNs topic.
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp
|
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
# The complication. It shares the API client, models, Keychain store and demo fixtures with the
|
||||||
|
# app, and reads the device token through the App Group.
|
||||||
|
WristCodeWidget:
|
||||||
|
type: app-extension
|
||||||
|
platform: watchOS
|
||||||
|
sources:
|
||||||
|
- WristCodeWidget
|
||||||
|
- WristCode/API
|
||||||
|
- WristCode/Auth
|
||||||
|
- WristCode/Demo
|
||||||
|
- WristCode/Shared
|
||||||
|
- WristCode/Views/Theme.swift
|
||||||
|
info:
|
||||||
|
path: WristCodeWidget/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: WristCode
|
||||||
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
|
NSExtension:
|
||||||
|
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||||
|
entitlements:
|
||||||
|
path: WristCodeWidget/WristCodeWidget.entitlements
|
||||||
|
properties:
|
||||||
|
com.apple.security.application-groups: [group.org.gbrown.wristcode]
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
# Extensions take the watch app's bundle ID as a prefix.
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp.widget
|
||||||
# The App Store only takes a watch-only app inside an empty iOS stub, the same pair Xcode's
|
# The App Store only takes a watch-only app inside an empty iOS stub, the same pair Xcode's
|
||||||
# "Watch-only App" template creates. The stub owns the App Store Connect record
|
# "Watch-only App" template creates. The stub owns the App Store Connect record
|
||||||
# (org.gbrown.wristcode) and embeds the watch app; it never installs on an iPhone.
|
# (org.gbrown.wristcode) and embeds the watch app; it never installs on an iPhone.
|
||||||
|
|||||||
Reference in new issue
Block a user