Files
wristcode/watch/WristCode/Views/FeedView.swift
T
gib 7ca0362745 Watch: status, environment and notifications screens
A gear on the feed opens a status overview that leads with what is wrong:
offline hosts, hosts on a fallback route, tokens expiring within 7 days,
and push problems, with healthy hosts folded into one row. Each host opens
its route ladder with Reconnect. The notifications page shows registration,
the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN
fallback, and snapshot tests render the three screens.
2026-11-01 15:16:18 -05:00

242 lines
9.2 KiB
Swift

// 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: .topBarLeading) {
NavigationLink(value: Route.status) {
Image(systemName: "gearshape.fill")
}
.accessibilityLabel("Status and settings")
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: Route.newThread) {
Image(systemName: "plus")
.fontWeight(.bold)
.foregroundStyle(.black)
}
.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: 4, bottom: 3, trailing: 4))
}
}
/// 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)
}