Watch: demo screenshots, wrapping reply chips, feed toolbar and inset fixes, README

Screenshots of Pair, Feed, Thread (finished, approval, question) and New
thread, taken in the Series 12 46mm simulator in demo mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
gibandClaude Opus 5.5 committed 2026-11-01 02:33:15 -05:00
1 parent bdfb79b891
commit 9731fd621f
10 files changed
+103 -3

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# WristCode watch app
SwiftUI, watch only, Swift 6 with strict concurrency, and no dependencies. It talks to the gateway
over HTTPS + JSON as described in [docs/api.md](../docs/api.md); `WristCode/API/Models.swift`
mirrors [gateway/src/contract.ts](../gateway/src/contract.ts).
## Build and test
```sh
cd watch
xcodegen generate # the .xcodeproj is generated and gitignored
xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' build
xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' test
```
## Demo mode
`DemoGateway` serves fixture data shaped like the docs/api.md examples, and it reacts to writes:
Allow moves a thread to working, a reply restarts a finished thread. The SwiftUI previews use it,
the pairing screen has a "Try the demo" button, and launch arguments open a screen directly:
```sh
xcrun simctl launch <udid> org.gbrown.wristcode -demo # feed
xcrun simctl launch <udid> org.gbrown.wristcode -demo -thread littleman/t-next # thread
xcrun simctl launch <udid> org.gbrown.wristcode -demo -screen new # new thread
```
`screenshots/` holds the screens captured this way.
## Layout
| Path | Job |
| --- | --- |
| `WristCodeApp.swift` | App entry, root navigation, `WKApplicationDelegate` for APNs registration |
| `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 |
| `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 |
+3 -1
View File
@@ -41,6 +41,8 @@ struct FeedView: View {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: Route.newThread) {
Image(systemName: "plus")
.fontWeight(.bold)
.foregroundStyle(.black)
}
.accessibilityLabel("New thread")
}
@@ -61,7 +63,7 @@ struct FeedView: View {
private extension View {
func feedRow() -> some View {
listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0))
.listRowInsets(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4))
}
}
+57
View File
@@ -110,3 +110,60 @@ enum Format {
return words.joined(separator: " ")
}
}
/// Lays children out left to right and wraps them onto new lines, like the mock's reply chips.
struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews)
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: rows.last.map { $0.y + $0.height } ?? 0)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
for row in arrange(width: bounds.width, subviews: subviews) {
var x = bounds.minX
for index in row.indices {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(at: CGPoint(x: x, y: bounds.minY + row.y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
}
}
private struct Row {
var indices: [Int] = []
var y: CGFloat = 0
var width: CGFloat = 0
var height: CGFloat = 0
}
private func arrange(width: CGFloat, subviews: Subviews) -> [Row] {
var rows: [Row] = []
var row = Row()
for index in subviews.indices {
let size = subviews[index].sizeThatFits(.unspecified)
if !row.indices.isEmpty, row.width + spacing + size.width > width {
rows.append(row)
row = Row(y: row.y + row.height + spacing)
}
row.width += (row.indices.isEmpty ? 0 : spacing) + size.width
row.height = max(row.height, size.height)
row.indices.append(index)
}
if !row.indices.isEmpty { rows.append(row) }
return rows
}
}
/// A content-sized chip, for one-tap replies.
struct ChipButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Theme.card.opacity(configuration.isPressed ? 0.6 : 1), in: Capsule())
}
}
+2 -2
View File
@@ -278,10 +278,10 @@ private struct QuickReplies: View {
var send: (String) -> Void
var body: some View {
HStack(spacing: 6) {
FlowLayout {
ForEach(["Yes", "Continue", "Commit it"], id: \.self) { reply in
Button(reply) { send(reply) }
.buttonStyle(ChoiceButtonStyle(fill: Theme.card))
.buttonStyle(ChipButtonStyle())
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB