diff --git a/watch/README.md b/watch/README.md new file mode 100644 index 0000000..8c6a09c --- /dev/null +++ b/watch/README.md @@ -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 org.gbrown.wristcode -demo # feed +xcrun simctl launch org.gbrown.wristcode -demo -thread littleman/t-next # thread +xcrun simctl launch 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 | diff --git a/watch/WristCode/Views/FeedView.swift b/watch/WristCode/Views/FeedView.swift index 8d7e908..11792f1 100644 --- a/watch/WristCode/Views/FeedView.swift +++ b/watch/WristCode/Views/FeedView.swift @@ -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)) } } diff --git a/watch/WristCode/Views/Theme.swift b/watch/WristCode/Views/Theme.swift index 8d94f50..098d8bc 100644 --- a/watch/WristCode/Views/Theme.swift +++ b/watch/WristCode/Views/Theme.swift @@ -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()) + } +} diff --git a/watch/WristCode/Views/ThreadView.swift b/watch/WristCode/Views/ThreadView.swift index 9aee869..aa67ea6 100644 --- a/watch/WristCode/Views/ThreadView.swift +++ b/watch/WristCode/Views/ThreadView.swift @@ -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()) } } } diff --git a/watch/screenshots/feed.png b/watch/screenshots/feed.png new file mode 100644 index 0000000..946b9a5 Binary files /dev/null and b/watch/screenshots/feed.png differ diff --git a/watch/screenshots/new-thread.png b/watch/screenshots/new-thread.png new file mode 100644 index 0000000..5c2840f Binary files /dev/null and b/watch/screenshots/new-thread.png differ diff --git a/watch/screenshots/pair.png b/watch/screenshots/pair.png new file mode 100644 index 0000000..322aced Binary files /dev/null and b/watch/screenshots/pair.png differ diff --git a/watch/screenshots/thread-approval.png b/watch/screenshots/thread-approval.png new file mode 100644 index 0000000..aa97cb3 Binary files /dev/null and b/watch/screenshots/thread-approval.png differ diff --git a/watch/screenshots/thread-question.png b/watch/screenshots/thread-question.png new file mode 100644 index 0000000..553824c Binary files /dev/null and b/watch/screenshots/thread-question.png differ diff --git a/watch/screenshots/thread.png b/watch/screenshots/thread.png new file mode 100644 index 0000000..a4abd6e Binary files /dev/null and b/watch/screenshots/thread.png differ