diff --git a/watch/README.md b/watch/README.md index d7c566f..33ec6d5 100644 --- a/watch/README.md +++ b/watch/README.md @@ -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 org.gbrown.wristcode.watchkitapp -demo -thread little xcrun simctl launch 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//`). 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 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 | diff --git a/watch/WristCode/Views/ThreadView.swift b/watch/WristCode/Views/ThreadView.swift index 8938858..2b3dbd3 100644 --- a/watch/WristCode/Views/ThreadView.swift +++ b/watch/WristCode/Views/ThreadView.swift @@ -18,24 +18,52 @@ struct ThreadView: View { @State private var selections: [String: Set] = [:] @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] = [:], 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 } @@ -360,7 +388,7 @@ private struct QuickReplies: View { #Preview("Multi-select") { NavigationStack { - ThreadView(hostId: "mac-mini", threadId: "t-release") + ThreadView(hostId: "mac-mini", threadId: "t-release", selections: ["checks": ["unit", "lint"]]) } .environment(AppModel.preview()) } diff --git a/watch/WristCodeTests/SnapshotTests.swift b/watch/WristCodeTests/SnapshotTests.swift new file mode 100644 index 0000000..ca69311 --- /dev/null +++ b/watch/WristCodeTests/SnapshotTests.swift @@ -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) + } + } + } +} diff --git a/watch/WristCodeWidget/WaitingView.swift b/watch/WristCodeWidget/WaitingView.swift index e77ff50..20e6a65 100644 --- a/watch/WristCodeWidget/WaitingView.swift +++ b/watch/WristCodeWidget/WaitingView.swift @@ -1,12 +1,43 @@ // 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 family + @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 @@ -179,39 +210,3 @@ struct WaitingView: View { } } } - -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) -} diff --git a/watch/WristCodeWidget/WaitingWidget.swift b/watch/WristCodeWidget/WaitingWidget.swift index 601b3a7..a43679c 100644 --- a/watch/WristCodeWidget/WaitingWidget.swift +++ b/watch/WristCodeWidget/WaitingWidget.swift @@ -23,31 +23,6 @@ struct WaitingWidget: Widget { } } -struct WaitingEntry: TimelineEntry { - enum Content: Equatable { - /// Counts from the gateway. `stale` when they are the last ones seen because the gateway - /// could not be reached. - case counts(Summary, stale: Bool) - case notPaired - /// The gateway could not be reached and there is nothing saved to show. - case unavailable - } - - var date: Date - var content: Content - - /// Ranks the complication higher in the Smart Stack while something waits. - var relevance: TimelineEntryRelevance? { - guard case let .counts(summary, _) = content, summary.needsYou > 0 else { return nil } - return TimelineEntryRelevance(score: Float(summary.needsYou)) - } - - static let sample = WaitingEntry(date: .now, content: .counts(Summary( - needsYou: 2, working: 3, - next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini"), - hostsOffline: 0), stale: false)) -} - struct WaitingProvider: TimelineProvider { /// How long until WidgetKit asks again. The app reloads sooner when it learns something. static let refreshInterval: TimeInterval = 15 * 60 @@ -92,3 +67,39 @@ struct WaitingProvider: TimelineProvider { } } } + +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) +} diff --git a/watch/project.yml b/watch/project.yml index 08666d9..2ffb91e 100644 --- a/watch/project.yml +++ b/watch/project.yml @@ -19,6 +19,8 @@ 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 diff --git a/watch/screenshots/complication.png b/watch/screenshots/complication.png new file mode 100644 index 0000000..e350527 Binary files /dev/null and b/watch/screenshots/complication.png differ diff --git a/watch/screenshots/thread-multiselect.png b/watch/screenshots/thread-multiselect.png new file mode 100644 index 0000000..4bb4aba Binary files /dev/null and b/watch/screenshots/thread-multiselect.png differ