Compare commits

..
2 Commits
Author SHA1 Message Date
gibandClaude Opus 5.5 67992dbf43 Watch: snapshot tests render the complication and multi-select screenshots
ImageRenderer draws the real views with demo data, since the watch simulator
is not drivable here. README covers the widget target and how to add it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:06:38 -05:00
gibandClaude Opus 5.5 0994fd7d5a 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]>
2026-11-01 11:06:38 -05:00
22 changed files with 978 additions and 40 deletions

No files matched your search

+30 -2
View File
@@ -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 |
+5
View File
@@ -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])
}
+66 -1
View File
@@ -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 }
+42
View File
@@ -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
}
}
}
+47 -8
View File
@@ -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() }
}
}
+52 -3
View File
@@ -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)
}
+27 -1
View File
@@ -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"),
+11
View File
@@ -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()
}
+22
View File
@@ -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) }
}
}
+109 -24
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -40,6 +40,7 @@ struct RootView: View {
}
.fontDesign(.rounded)
.tint(Theme.work)
.onOpenURL { model.open(url: $0) }
}
}
+67 -1
View File
@@ -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 })
}
}
+97
View File
@@ -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)
}
}
}
}
+29
View File
@@ -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>
+212
View File
@@ -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"
}
}
}
+105
View File
@@ -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>
+40
View File
@@ -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