Watch app MVP: XcodeGen project, gateway client, pair, feed, thread, new thread, notifications
SwiftUI watch-only app in watch/, generated by XcodeGen. Codable models mirror gateway/src/contract.ts, with lenient string enums so new phases or decisions still decode. A DemoGateway serves fixtures from the docs/api.md examples for previews and demo mode. Swift Testing covers decoding the documented JSON, request encoding, notification action parsing and the small view helpers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ba1624eb9e
commit
bdfb79b891
19 files changed
+2413
No files matched your search
@@ -0,0 +1,233 @@
|
||||
// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first
|
||||
// (with Allow and Deny on the card), then working (with what the agent is doing), then recent.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FeedView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var visible = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
FeedHeader(feed: model.feed, error: model.feedError)
|
||||
.feedRow()
|
||||
|
||||
if let feed = model.feed {
|
||||
ForEach(feed.needsYou) { item in
|
||||
NeedsYouCard(item: item).feedRow()
|
||||
}
|
||||
ForEach(feed.working) { item in
|
||||
StatusCard(item: item).feedRow()
|
||||
}
|
||||
ForEach(feed.recent) { item in
|
||||
StatusCard(item: item).feedRow()
|
||||
}
|
||||
if feed.needsYou.isEmpty && feed.working.isEmpty && feed.recent.isEmpty {
|
||||
Text("No threads in the last day.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.feedRow()
|
||||
}
|
||||
} else if model.feedError != nil {
|
||||
Button("Try again") { Task { await model.refreshFeed() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.refreshable { await model.refreshFeed() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink(value: Route.newThread) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("New thread")
|
||||
}
|
||||
}
|
||||
.onAppear { visible = true }
|
||||
.onDisappear { visible = false }
|
||||
// Refresh on appear, when the app comes to the foreground, and every 10 s while on screen.
|
||||
.task(id: visible && scenePhase == .active) {
|
||||
guard visible && scenePhase == .active else { return }
|
||||
while !Task.isCancelled {
|
||||
await model.refreshFeed()
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func feedRow() -> some View {
|
||||
listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0))
|
||||
}
|
||||
}
|
||||
|
||||
/// The big count of threads waiting on you, plus offline hosts and refresh errors.
|
||||
private struct FeedHeader: View {
|
||||
var feed: Feed?
|
||||
var error: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let feed {
|
||||
let waiting = feed.needsYou.count
|
||||
Text("\(waiting)")
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(waiting > 0 ? Theme.you : Theme.done)
|
||||
Text(subtitle(feed))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
ForEach(feed.hosts.filter { $0.status != .online }) { host in
|
||||
Label("\(host.label) \(host.status.problem ?? "")", systemImage: "wifi.slash")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.done)
|
||||
}
|
||||
} else if error == nil {
|
||||
Text("Loading")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.done)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 4)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func subtitle(_ feed: Feed) -> String {
|
||||
let working = feed.working.count
|
||||
let lead = feed.needsYou.isEmpty ? "nothing waiting" : "waiting on you"
|
||||
return working > 0 ? "\(lead) · \(working) working" : lead
|
||||
}
|
||||
}
|
||||
|
||||
/// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline.
|
||||
private struct NeedsYouCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var item: FeedItem
|
||||
@State private var sending = false
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
Card(edge: Theme.you) {
|
||||
Button { model.path.append(item.route) } label: {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("\(item.hostLabel) · \(item.projectTitle)")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Theme.you)
|
||||
.lineLimit(1)
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.lineLimit(2)
|
||||
if let request = item.request {
|
||||
Text(request.summary)
|
||||
.font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(3)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let request = item.request, let quick = request.quickOptions {
|
||||
HStack(spacing: 6) {
|
||||
Button(quick.allow.label) { respond(request, quick.allow.decision) }
|
||||
.buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black))
|
||||
Button(quick.deny.label) { respond(request, quick.deny.decision) }
|
||||
.buttonStyle(ChoiceButtonStyle())
|
||||
}
|
||||
.disabled(sending)
|
||||
.opacity(sending ? 0.5 : 1)
|
||||
.padding(.top, 7)
|
||||
} else if item.request?.isQuestion == true {
|
||||
Button("Answer") { model.path.append(item.route) }
|
||||
.buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black))
|
||||
.padding(.top, 7)
|
||||
}
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) {
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.api.respond(
|
||||
hostId: item.hostId, threadId: item.threadId,
|
||||
request: .decision(requestId: request.id, decision: decision))
|
||||
} catch let failure as GatewayError where failure.isStale {
|
||||
// Someone answered it already; the refresh below drops the card.
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
await model.refreshFeed()
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A teal card for a working thread, or a grey one for a recent thread.
|
||||
private struct StatusCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var item: FeedItem
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(item.route) } label: {
|
||||
Card(edge: item.phase.color) {
|
||||
Text(meta)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(item.phase == .failed ? Theme.secondaryText : item.phase.color)
|
||||
.lineLimit(1)
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(item.phase.isWorking ? Color.white : Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 1)
|
||||
if item.phase.isWorking, let line = item.lastMessage {
|
||||
Text(line)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var meta: String {
|
||||
if item.phase.isWorking {
|
||||
return "\(item.hostLabel) · \(item.projectTitle)"
|
||||
}
|
||||
let age = Format.age(of: item.updatedAt)
|
||||
let state = item.phase == .failed ? "Failed" : age == "now" ? "just now" : "\(age) ago"
|
||||
return "\(item.hostLabel) · \(state)"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Feed") {
|
||||
NavigationStack {
|
||||
FeedView()
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
Reference in new issue
Block a user