Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67992dbf43 | ||
|
|
0994fd7d5a |
No files matched your search
+30
-2
@@ -22,6 +22,7 @@ targets, the same pair Xcode's "Watch-only App" template makes:
|
||||
| --- | --- | --- |
|
||||
| `WristCodeContainer` | `org.gbrown.wristcode` | iOS stub that owns the App Store Connect record and embeds the watch app |
|
||||
| `WristCode` | `org.gbrown.wristcode.watchkitapp` | The watch app, and the APNs topic |
|
||||
| `WristCodeWidget` | `org.gbrown.wristcode.watchkitapp.widget` | The complication, embedded in the watch app |
|
||||
|
||||
```sh
|
||||
scripts/testflight.sh # archive, export and upload to App Store Connect
|
||||
@@ -46,7 +47,31 @@ xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -thread little
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new # new thread
|
||||
```
|
||||
|
||||
`screenshots/` holds the screens captured this way.
|
||||
`screenshots/` holds the screens captured this way. The complication and the multi-select question
|
||||
are rendered by `SnapshotTests` instead, which only run when given a directory:
|
||||
|
||||
```sh
|
||||
TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \
|
||||
-destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' test \
|
||||
-only-testing:WristCodeTests/SnapshotTests
|
||||
```
|
||||
|
||||
In demo mode the complication shows the demo counts too.
|
||||
|
||||
## Complication
|
||||
|
||||
`WristCodeWidget` shows how many threads wait on you, from `GET /v1/summary`, in the circular,
|
||||
rectangular, inline and corner families. Tapping it opens the app, at the longest-waiting thread when
|
||||
there is one (`wristcode://thread/<hostId>/<threadId>`). WidgetKit refreshes it about every 15 minutes;
|
||||
the app reloads it sooner when its feed counts change, when a push arrives in the foreground, and
|
||||
after a notification action.
|
||||
|
||||
The app and the extension share the App Group `group.org.gbrown.wristcode`. The device token and
|
||||
gateway URL live in that group's Keychain access group, and the demo flag and last summary in its
|
||||
defaults. Builds before the complication kept the token in the app's own Keychain group;
|
||||
`DeviceStore.migrateToSharedGroup()` moves it on launch, so a paired watch stays paired.
|
||||
|
||||
To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, then WristCode.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -56,7 +81,10 @@ xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new
|
||||
| `AppModel.swift` | Paired, demo or unpaired; navigation path; the feed; 401 sends you back to pairing |
|
||||
| `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client |
|
||||
| `API/Models.swift` | Codable mirror of the contract |
|
||||
| `Auth/DeviceStore.swift` | Device token and gateway URL in the Keychain |
|
||||
| `API/Route.swift` | Screens the app can open, and their `wristcode://` URLs |
|
||||
| `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group |
|
||||
| `Shared/SharedState.swift` | Demo flag and last summary in the App Group, for the complication |
|
||||
| `Demo/DemoGateway.swift` | In-memory gateway with fixtures |
|
||||
| `Views/` | Pair, Feed, Thread, New thread, and the mock B theme |
|
||||
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions |
|
||||
| `../WristCodeWidget/` | The complication: timeline provider, and one view per family |
|
||||
@@ -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,27 +13,57 @@ 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
|
||||
|
||||
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
|
||||
private let scrolls: Bool
|
||||
|
||||
/// `detail` and `selections` start the screen with a thread loaded and options ticked, for
|
||||
/// snapshots and previews.
|
||||
init(
|
||||
hostId: String, threadId: String,
|
||||
detail: ThreadDetail? = nil, selections: [String: Set<String>] = [:], scrolls: Bool = true
|
||||
) {
|
||||
self.hostId = hostId
|
||||
self.threadId = threadId
|
||||
self.scrolls = scrolls
|
||||
_detail = State(initialValue: detail)
|
||||
_selections = State(initialValue: selections)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if scrolls {
|
||||
screen
|
||||
} else {
|
||||
column
|
||||
}
|
||||
}
|
||||
|
||||
private var column: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let detail {
|
||||
content(detail)
|
||||
} else if error == nil {
|
||||
Text("Loading")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var screen: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let detail {
|
||||
content(detail)
|
||||
} else if error == nil {
|
||||
Text("Loading")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
column
|
||||
}
|
||||
.onChange(of: detail?.messages.last?.id) {
|
||||
guard !scrolledToLatest, detail != nil else { return }
|
||||
@@ -152,19 +182,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 +215,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 +295,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 +385,10 @@ private struct QuickReplies: View {
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
|
||||
#Preview("Multi-select") {
|
||||
NavigationStack {
|
||||
ThreadView(hostId: "mac-mini", threadId: "t-release", selections: ["checks": ["unit", "lint"]])
|
||||
}
|
||||
.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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,33 @@ private let feedItemJSON = """
|
||||
#expect(request.quickOptions == nil)
|
||||
#expect(request.summary == "Hash the codes?")
|
||||
#expect(request.questions.first?.allowCustomAnswer == true)
|
||||
// A gateway older than multi-select leaves the flag out, which means single-select.
|
||||
#expect(request.questions.first?.multiSelect == false)
|
||||
}
|
||||
|
||||
@Test func multiSelectQuestion() throws {
|
||||
let question = try decode(PendingRequest.Question.self, """
|
||||
{ "id": "q1", "header": "Checks", "question": "Which checks?",
|
||||
"options": [{ "label": "Unit", "value": "unit" }, { "label": "Lint", "value": "lint" }],
|
||||
"allowCustomAnswer": false, "multiSelect": true }
|
||||
""")
|
||||
#expect(question.multiSelect)
|
||||
#expect(question.options.map(\.value) == ["unit", "lint"])
|
||||
}
|
||||
|
||||
@Test func summaryFromDocs() throws {
|
||||
let summary = try decode(Summary.self, """
|
||||
{ "needsYou": 1, "working": 3, "hostsOffline": 0,
|
||||
"next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini" } }
|
||||
""")
|
||||
#expect(summary.needsYou == 1)
|
||||
#expect(summary.working == 3)
|
||||
#expect(summary.next?.title == "Receipt OCR")
|
||||
#expect(summary.next?.route == .thread(hostId: "h", threadId: "t"))
|
||||
|
||||
let idle = try decode(Summary.self, #"{ "needsYou": 0, "working": 0, "hostsOffline": 2, "next": null }"#)
|
||||
#expect(idle.next == nil)
|
||||
#expect(idle.hostsOffline == 2)
|
||||
}
|
||||
|
||||
@Test func unknownEnumValuesStillDecode() throws {
|
||||
@@ -127,11 +154,19 @@ private let feedItemJSON = """
|
||||
}
|
||||
|
||||
@Test func respondWithAnswers() throws {
|
||||
let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": "yes"]))
|
||||
let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": .text("yes")]))
|
||||
#expect(object["answers"] as? [String: String] == ["q1": "yes"])
|
||||
#expect(object["decision"] == nil)
|
||||
}
|
||||
|
||||
@Test func respondWithMultiSelectAnswers() throws {
|
||||
let object = try encodeObject(RespondRequest.answers(
|
||||
requestId: "r1", answers: ["checks": .values(["unit", "lint"]), "note": .text("after lunch")]))
|
||||
let answers = try #require(object["answers"] as? [String: Any])
|
||||
#expect(answers["checks"] as? [String] == ["unit", "lint"])
|
||||
#expect(answers["note"] as? String == "after lunch")
|
||||
}
|
||||
|
||||
@Test func pushRegistration() throws {
|
||||
let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox))
|
||||
#expect(object["apnsEnvironment"] as? String == "sandbox")
|
||||
@@ -185,6 +220,25 @@ private let feedItemJSON = """
|
||||
== ProjectRef(hostId: "on", projectId: "a"))
|
||||
}
|
||||
|
||||
@Test func summaryFromFeed() throws {
|
||||
let feed = DemoFixtures.previewFeed()
|
||||
let summary = Summary(feed)
|
||||
#expect(summary.needsYou == feed.needsYou.count)
|
||||
#expect(summary.working == feed.working.count)
|
||||
#expect(summary.hostsOffline == 1)
|
||||
#expect(summary.next?.threadId == feed.needsYou.first?.threadId)
|
||||
#expect(Summary(Feed(needsYou: [], working: [], recent: [], hosts: [])).next == nil)
|
||||
}
|
||||
|
||||
@Test func routeURLs() {
|
||||
let thread = Route.thread(hostId: "mac mini", threadId: "t/1")
|
||||
#expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1")
|
||||
#expect(Route(url: thread.url) == thread)
|
||||
#expect(Route(url: Route.newThread.url) == .newThread)
|
||||
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
|
||||
#expect(Route(url: URL(string: "https://thread/h/t")!) == nil)
|
||||
}
|
||||
|
||||
@Test func notificationCommands() {
|
||||
let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"]
|
||||
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil)
|
||||
@@ -223,4 +277,16 @@ private let feedItemJSON = """
|
||||
request: .decision(requestId: item.request!.id, decision: .accept))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func multiSelectAnswerMovesThreadToWorking() async throws {
|
||||
let gateway = DemoGateway()
|
||||
let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true })
|
||||
let request = try #require(item.request)
|
||||
let question = try #require(request.questions.first)
|
||||
try await gateway.respond(
|
||||
hostId: item.hostId, threadId: item.threadId,
|
||||
request: .answers(requestId: request.id, answers: [question.id: .values(["unit", "lint"])]))
|
||||
#expect(try await gateway.summary().working == 3)
|
||||
#expect(try await gateway.feed().working.contains { $0.id == item.id })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Renders screens with demo data to PNGs in watch/screenshots. Skipped unless WC_SCREENSHOT_DIR is
|
||||
// set, which xcodebuild passes through from TEST_RUNNER_WC_SCREENSHOT_DIR:
|
||||
//
|
||||
// TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild ... test -only-testing:WristCodeTests/SnapshotTests
|
||||
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
import WidgetKit
|
||||
@testable import WristCode
|
||||
|
||||
private let outputDirectory = ProcessInfo.processInfo.environment["WC_SCREENSHOT_DIR"]
|
||||
|
||||
/// The Series 12 (46mm) screen in points.
|
||||
private let screen = CGSize(width: 208, height: 248)
|
||||
|
||||
@MainActor @Suite(.enabled(if: outputDirectory != nil))
|
||||
struct SnapshotTests {
|
||||
@Test func complication() async throws {
|
||||
let gateway = DemoGateway()
|
||||
let waiting = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
|
||||
let feed = try await gateway.feed()
|
||||
for item in feed.needsYou {
|
||||
try await gateway.respond(
|
||||
hostId: item.hostId, threadId: item.threadId,
|
||||
request: .decision(requestId: item.request!.id, decision: .decline))
|
||||
}
|
||||
let idle = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
|
||||
|
||||
try write(ComplicationGallery(waiting: waiting, idle: idle), "complication.png")
|
||||
}
|
||||
|
||||
@Test func multiSelectQuestion() async throws {
|
||||
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-release")
|
||||
let view = ThreadView(
|
||||
hostId: "mac-mini", threadId: "t-release", detail: detail,
|
||||
selections: ["checks": ["unit", "lint"]], scrolls: false)
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
.tint(Theme.work)
|
||||
try write(view, "thread-multiselect.png")
|
||||
}
|
||||
|
||||
/// Renders at the screen's width and as tall as the content, as if scrolled through.
|
||||
private func write(_ view: some View, _ name: String) throws {
|
||||
let renderer = ImageRenderer(content: view
|
||||
.frame(width: screen.width)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.bottom, 8)
|
||||
.background(.black)
|
||||
.environment(\.colorScheme, .dark))
|
||||
renderer.scale = 2
|
||||
let image = try #require(renderer.cgImage)
|
||||
let url = URL(fileURLWithPath: try #require(outputDirectory)).appending(path: name)
|
||||
let destination = try #require(CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil))
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
#expect(CGImageDestinationFinalize(destination))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every family at its 46mm size, once with threads waiting and once with nothing waiting.
|
||||
private struct ComplicationGallery: View {
|
||||
var waiting: WaitingEntry
|
||||
var idle: WaitingEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
section("3 waiting", waiting)
|
||||
section("Nothing waiting", idle)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private func section(_ title: String, _ entry: WaitingEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Theme.done)
|
||||
WaitingView(entry: entry, familyOverride: .accessoryInline)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.frame(height: 18)
|
||||
WaitingView(entry: entry, familyOverride: .accessoryRectangular)
|
||||
.frame(width: 184, height: 72)
|
||||
HStack(spacing: 16) {
|
||||
// Outside a face AccessoryWidgetBackground fills a square; a face clips it round.
|
||||
WaitingView(entry: entry, familyOverride: .accessoryCircular)
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(Circle())
|
||||
WaitingView(entry: entry, familyOverride: .accessoryCorner)
|
||||
.frame(width: 50, height: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,212 @@
|
||||
// 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.
|
||||
//
|
||||
// The app target compiles this file too, so the snapshot tests can render the complication.
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
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 WaitingView: View {
|
||||
@Environment(\.widgetFamily) private var widgetFamily
|
||||
var entry: WaitingEntry
|
||||
/// Overrides the environment's family, for rendering outside a widget.
|
||||
var familyOverride: WidgetFamily?
|
||||
|
||||
private var family: WidgetFamily { familyOverride ?? widgetFamily }
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,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,11 @@ targets:
|
||||
platform: watchOS
|
||||
sources:
|
||||
- WristCode
|
||||
# The complication's views, so the snapshot tests can render them.
|
||||
- WristCodeWidget/WaitingView.swift
|
||||
dependencies:
|
||||
# Embedded in the watch app's PlugIns folder.
|
||||
- target: WristCodeWidget
|
||||
info:
|
||||
path: WristCode/Info.plist
|
||||
properties:
|
||||
@@ -32,18 +37,53 @@ targets:
|
||||
WKWatchOnly: true
|
||||
# HTTPS only, so no export compliance documentation is needed.
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
# wristcode://thread/<hostId>/<threadId>, which the complication opens.
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: org.gbrown.wristcode
|
||||
CFBundleURLSchemes: [wristcode]
|
||||
entitlements:
|
||||
path: WristCode/WristCode.entitlements
|
||||
properties:
|
||||
# Archives are signed for development; exporting for App Store Connect re-signs with the
|
||||
# distribution profile, which turns this into production.
|
||||
aps-environment: development
|
||||
# Shared with the complication: the App Group's defaults, and its Keychain access group
|
||||
# for the device token.
|
||||
com.apple.security.application-groups: [group.org.gbrown.wristcode]
|
||||
settings:
|
||||
base:
|
||||
# The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also
|
||||
# the APNs topic.
|
||||
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# The complication. It shares the API client, models, Keychain store and demo fixtures with the
|
||||
# app, and reads the device token through the App Group.
|
||||
WristCodeWidget:
|
||||
type: app-extension
|
||||
platform: watchOS
|
||||
sources:
|
||||
- WristCodeWidget
|
||||
- WristCode/API
|
||||
- WristCode/Auth
|
||||
- WristCode/Demo
|
||||
- WristCode/Shared
|
||||
- WristCode/Views/Theme.swift
|
||||
info:
|
||||
path: WristCodeWidget/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: WristCode
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
entitlements:
|
||||
path: WristCodeWidget/WristCodeWidget.entitlements
|
||||
properties:
|
||||
com.apple.security.application-groups: [group.org.gbrown.wristcode]
|
||||
settings:
|
||||
base:
|
||||
# Extensions take the watch app's bundle ID as a prefix.
|
||||
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp.widget
|
||||
# The App Store only takes a watch-only app inside an empty iOS stub, the same pair Xcode's
|
||||
# "Watch-only App" template creates. The stub owns the App Store Connect record
|
||||
# (org.gbrown.wristcode) and embeds the watch app; it never installs on an iPhone.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Reference in new issue
Block a user