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.
This commit is contained in:
gib committed 2026-11-01 15:16:18 -05:00
1 parent 9707daf1fe
commit 7ca0362745
17 files changed
+1063 -14

No files matched your search

+6
View File
@@ -38,6 +38,12 @@ struct FeedView: View {
.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")
+185
View File
@@ -0,0 +1,185 @@
// What the status overview says about a `StatusResponse`: a headline, the issues worst first, and
// the healthy hosts folded into one row. Worked out at render, so relative times stay current.
import Foundation
struct StatusIssue: Hashable, Identifiable {
enum Severity: Int, Comparable {
case warning, problem
static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue }
}
enum Kind: Hashable {
case host, fallback, token, push
}
var id: String
var severity: Severity
var kind: Kind
var title: String
var detail: String?
/// The screen that explains it.
var route: Route
}
struct StatusReport {
let status: StatusResponse
let now: Date
init(_ status: StatusResponse, now: Date = .now) {
self.status = status
self.now = now
}
/// A host on its fallback route is still connected, so only that leaves the headline alone.
var allConnected: Bool { issues.allSatisfy { $0.kind == .fallback } }
var worst: StatusIssue.Severity? { issues.map(\.severity).max() }
/// "5 environments · pushes OK".
var subline: String {
let count = status.hosts.count
return "\(count) environment\(count == 1 ? "" : "s") · \(pushSummary)"
}
var pushesOK: Bool { pushIssues.isEmpty }
var pushSummary: String {
if !status.gateway.pushConfigured { return "pushes off" }
if !status.device.pushRegistered { return "watch not registered" }
if status.gateway.lastPush?.ok == false { return "last push failed" }
return "pushes OK"
}
var issues: [StatusIssue] {
var issues = status.hosts.compactMap(hostIssue)
if let token = tokenIssue { issues.append(token) }
issues += pushIssues
// Stable within a severity, so hosts keep the gateway's order.
return issues.enumerated()
.sorted { a, b in
a.element.severity == b.element.severity ? a.offset < b.offset : a.element.severity > b.element.severity
}
.map(\.element)
}
/// Hosts with nothing to say: online on their first route.
var healthy: [HostStatusDetail] {
status.hosts.filter { $0.status == .online && !$0.onFallback }
}
/// "4 more, all direct", or "all online" when they use different kinds of route.
var healthySummary: String? {
let hosts = healthy
guard !hosts.isEmpty else { return nil }
let kinds = Set(hosts.compactMap { $0.activeRoute?.kind })
let how = kinds.count == 1 ? "all \(kinds.first!.name)" : "all online"
let anyHostIssue = issues.contains { $0.kind == .host || $0.kind == .fallback }
if anyHostIssue { return "\(hosts.count) more, \(how)" }
return "\(hosts.count) environment\(hosts.count == 1 ? "" : "s"), \(how)"
}
// MARK: - Issues
private func hostIssue(_ host: HostStatusDetail) -> StatusIssue? {
let route = Route.host(id: host.id)
if host.onFallback, let active = host.activeRoute {
return StatusIssue(
id: "fallback-\(host.id)", severity: .warning, kind: .fallback,
title: "\(host.label) is on its \(active.kind.name) route",
detail: host.routes.first.map(Self.failure), route: route)
}
switch host.status {
case .online:
return nil
case .connecting:
return StatusIssue(
id: "host-\(host.id)", severity: .warning, kind: .host,
title: "\(host.label) connecting", detail: nil, route: route)
case .authInvalid:
return StatusIssue(
id: "host-\(host.id)", severity: .problem, kind: .host,
title: "\(host.label) needs a new token", detail: "Run renew-hosts.sh", route: route)
case .incompatible:
return StatusIssue(
id: "host-\(host.id)", severity: .problem, kind: .host,
title: "\(host.label) is incompatible", detail: host.error, route: route)
default:
return StatusIssue(
id: "host-\(host.id)", severity: .problem, kind: .host,
title: "\(host.label) offline", detail: offlineDetail(host), route: route)
}
}
/// "Both routes failed · 4 min", or the error when no route was tried.
private func offlineDetail(_ host: HostStatusDetail) -> String? {
let failed = host.routes.filter { $0.result == .failed }.count
let cause: String? = switch failed {
case 0: host.error
case host.routes.count: host.routes.count == 1 ? "Its route failed" : host.routes.count == 2 ? "Both routes failed" : "All \(failed) routes failed"
default: "\(failed) of \(host.routes.count) routes failed"
}
let since = host.lastOnlineAt.map { Format.age(of: $0, now: now) }
return [cause, since].compactMap(\.self).joined(separator: " · ").nilIfEmpty
}
/// Tokens that expire within 7 days, as one issue.
private var tokenIssue: StatusIssue? {
let soon = status.hosts.compactMap { host in
host.tokenExpiresAt.map { (host, $0) }
}
.filter { $0.1.timeIntervalSince(now) < 7 * 86_400 }
.sorted { $0.1 < $1.1 }
guard let (first, expiry) = soon.first else { return nil }
let when = Format.expiry(expiry, now: now)
let title = soon.count == 1 ? "\(first.label) token \(when)" : "\(soon.count) tokens \(when)"
return StatusIssue(
id: "token", severity: .warning, kind: .token, title: title,
detail: "Run renew-hosts.sh", route: soon.count == 1 ? .host(id: first.id) : .status)
}
private var pushIssues: [StatusIssue] {
let gateway = status.gateway
if !gateway.pushConfigured {
return [StatusIssue(
id: "push", severity: .problem, kind: .push, title: "Pushes are off",
detail: "The gateway has no APNs key", route: .notifications)]
}
if !status.device.pushRegistered {
return [StatusIssue(
id: "push", severity: .warning, kind: .push, title: "This watch gets no pushes",
detail: "It has not registered with the gateway", route: .notifications)]
}
if let push = gateway.lastPush, !push.ok {
return [StatusIssue(
id: "push", severity: .problem, kind: .push, title: "Last push failed",
detail: push.reason ?? "Status \(push.status)", route: .notifications)]
}
return []
}
/// "laptop.gib did not answer", or the host and its error.
static func failure(of route: HostRoute) -> String {
guard let error = route.error else { return "\(route.hostPort) did not connect" }
if error.hasPrefix("no answer") { return "\(route.hostPort) did not answer" }
return "\(route.hostPort): \(error)"
}
}
extension PushResult {
/// A short hint for a failed push, when the cause is a common one.
var hint: String? {
guard !ok else { return nil }
if status == 0 { return "The push never reached Apple." }
return switch reason {
case "BadDeviceToken": "Usually a sandbox/production mismatch. Re-pair this build."
case "Unregistered": "Apple no longer knows this watch. Reopen the app to register again."
case "DeviceTokenNotForTopic": "The gateway's APNs topic is not this app's bundle ID."
default: nil
}
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
+506
View File
@@ -0,0 +1,506 @@
// Settings mock C: an overview that leads with what is wrong, an environment page with mock B's
// route ladder, and a notifications page with a test push and Unpair. Each screen loads
// `GET /v1/status` when it appears; nothing polls.
import SwiftUI
/// The overview: a headline, the issues worst first, then the healthy hosts folded into one row.
struct StatusView: View {
@Environment(AppModel.self) private var model
@State private var expanded = false
var status: StatusResponse?
var scrolls = true
var body: some View {
StatusLoader(status: status, scrolls: scrolls) { status in
let report = StatusReport(status)
Headline(report: report)
ForEach(report.issues) { issue in
Button { model.path.append(issue.route) } label: {
Card(edge: issue.severity == .problem ? Theme.bad : Theme.you) {
RowText(title: issue.title, detail: issue.detail)
}
}
.buttonStyle(.plain)
}
if let summary = report.healthySummary {
Button { expanded.toggle() } label: {
Card(edge: Theme.done) {
HStack {
RowText(title: summary)
Spacer(minLength: 4)
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.secondaryText)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if expanded {
ForEach(report.healthy) { host in
Button { model.path.append(.host(id: host.id)) } label: {
Card(edge: Theme.work) {
HStack(spacing: 6) {
if let kind = host.activeRoute?.kind {
Image(systemName: kind.symbol)
.font(.system(size: 11))
.foregroundStyle(Theme.work)
}
RowText(title: host.label)
}
}
}
.buttonStyle(.plain)
}
}
}
Button { model.path.append(.notifications) } label: {
Card(edge: Theme.done) {
HStack(spacing: 6) {
Image(systemName: "bell.fill")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
RowText(title: "Notifications")
Spacer(minLength: 4)
Text(report.pushesOK ? "OK" : "Check")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(report.pushesOK ? Theme.work : Theme.you)
}
}
}
.buttonStyle(.plain)
.padding(.top, 4)
}
.navigationTitle("Status")
}
}
private struct Headline: View {
var report: StatusReport
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(report.allConnected ? "All connected" : "Needs attention")
.font(.system(size: 20, weight: .heavy, design: .rounded))
.foregroundStyle(report.allConnected ? Theme.work : report.worst == .problem ? Theme.bad : Theme.you)
Text(report.subline)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.bottom, 4)
.accessibilityElement(children: .combine)
}
}
/// One environment: where it stands, its routes as a ladder, and Reconnect.
struct HostStatusView: View {
@Environment(AppModel.self) private var model
let hostId: String
var status: StatusResponse?
var scrolls = true
@State private var reload = 0
@State private var reconnecting = false
@State private var error: String?
var body: some View {
StatusLoader(reload: reload, status: status, scrolls: scrolls) { status in
if let host = status.hosts.first(where: { $0.id == hostId }) {
content(host, now: .now)
} else {
RowText(title: "This environment is gone.")
}
}
}
@ViewBuilder
private func content(_ host: HostStatusDetail, now: Date) -> some View {
let line = statusLine(host)
Text(line.text)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(line.color)
.padding(.horizontal, 4)
.navigationTitle(host.label)
RouteLadder(host: host)
.padding(.horizontal, 4)
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 2) {
if host.status != .online {
let parts = [
host.nextRetryAt.map { "Next try in \(Format.countdown(to: $0, now: now))" },
host.lastOnlineAt.map { "last online \($0.formatted(date: .omitted, time: .shortened))" },
].compactMap(\.self)
if !parts.isEmpty {
Text(parts.joined(separator: " · "))
.foregroundStyle(Theme.secondaryText)
}
}
if let expiry = host.tokenExpiresAt {
let days = expiry.timeIntervalSince(now) / 86_400
Text("Token \(Format.expiry(expiry, now: now))")
.foregroundStyle(days < 0 ? Theme.bad : days < 7 ? Theme.you : Theme.secondaryText)
}
if let version = host.serverVersion {
Text("T3 \(version)")
.foregroundStyle(Theme.secondaryText)
}
}
.font(.system(size: 11))
.padding(.horizontal, 4)
Button(reconnecting ? "Reconnecting" : "Reconnect") { reconnect() }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
.disabled(reconnecting)
.padding(.top, 6)
if let error {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
}
private func statusLine(_ host: HostStatusDetail) -> (text: String, color: Color) {
switch host.status {
case .online:
let kind = host.activeRoute?.kind.name ?? "direct"
return host.onFallback ? ("On its \(kind) route", Theme.you) : ("Online · \(kind)", Theme.work)
case .connecting:
return ("Connecting", Theme.secondaryText)
case .authInvalid:
return ("Needs a new token", Theme.bad)
case .incompatible:
return (host.error.map { "Incompatible: \($0)" } ?? "Incompatible", Theme.bad)
default:
return ("Offline", Theme.bad)
}
}
/// Asks the gateway to start again from the first route, then looks again once it has had a
/// moment to try.
private func reconnect() {
reconnecting = true
error = nil
Task {
do {
try await model.api.reconnect(hostId: hostId)
try await Task.sleep(for: .seconds(2))
reload += 1
} catch {
self.error = model.message(for: error)
}
reconnecting = false
}
}
}
/// The routes in the order the gateway walks them, each with its kind and what happened.
private struct RouteLadder: View {
var host: HostStatusDetail
var body: some View {
VStack(alignment: .leading, spacing: 9) {
ForEach(Array(host.routes.enumerated()), id: \.offset) { _, route in
HStack(alignment: .top, spacing: 7) {
Image(systemName: route.kind.symbol)
.font(.system(size: 10))
.foregroundStyle(color(route))
.frame(width: 20, height: 20)
.background(Circle().fill(.black))
VStack(alignment: .leading, spacing: 1) {
Text(route.hostPort)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(route.result == .untried ? Theme.done : route.result == .ok ? Theme.work : .white)
.lineLimit(1)
.minimumScaleFactor(0.8)
if let detail = detail(route) {
Text(detail)
.font(.system(size: 11))
.foregroundStyle(color(route))
}
}
.padding(.top, 2)
}
}
}
.background(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.18))
.frame(width: 2)
.padding(.leading, 9)
.padding(.vertical, 10)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func color(_ route: HostRoute) -> Color {
switch route.result {
case .ok: Theme.work
case .failed: Theme.bad
default: Theme.done
}
}
private func detail(_ route: HostRoute) -> String? {
switch route.result {
case .failed:
return route.error ?? "failed"
case .ok:
let at = route.checkedAt ?? host.onlineSince
return at.map { "connected \($0.formatted(date: .omitted, time: .shortened))" } ?? "connected"
default:
return nil
}
}
}
/// Whether this watch gets pushes, the last push, a test push, the gateway, and Unpair.
struct NotificationsView: View {
@Environment(AppModel.self) private var model
var status: StatusResponse?
var scrolls = true
@State private var reload = 0
@State private var sending = false
@State private var result: PushResult?
@State private var error: String?
@State private var confirmingUnpair = false
/// `status` and `result` start the screen loaded and after a test push, for snapshots.
init(status: StatusResponse? = nil, result: PushResult? = nil, scrolls: Bool = true) {
self.status = status
self.scrolls = scrolls
_result = State(initialValue: result)
}
var body: some View {
StatusLoader(reload: reload, status: status, scrolls: scrolls) { status in
content(status, now: .now)
}
.navigationTitle("Notifications")
.confirmationDialog("Unpair this watch?", isPresented: $confirmingUnpair) {
Button("Unpair", role: .destructive) { Task { await model.unpair() } }
} message: {
Text("It stops getting notifications until you pair it again.")
}
}
@ViewBuilder
private func content(_ status: StatusResponse, now: Date) -> some View {
let device = status.device
Card(edge: device.pushRegistered ? Theme.work : Theme.you) {
HStack {
RowText(title: "This watch", detail: device.apnsEnvironment?.rawValue)
Spacer(minLength: 4)
Text(device.pushRegistered ? "registered" : "not registered")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(device.pushRegistered ? Theme.work : Theme.you)
}
}
if !status.gateway.pushConfigured {
Card(edge: Theme.bad) {
RowText(title: "Pushes are off", detail: "The gateway has no APNs key")
}
}
let last = status.gateway.lastPush
Card(edge: last.map { $0.ok ? Theme.work : Theme.bad } ?? Theme.done) {
HStack {
RowText(title: "Last push")
Spacer(minLength: 4)
if let last {
let age = Format.age(of: last.at, now: now)
Text("\(age == "now" ? "just now" : "\(age) ago") \(last.ok ? "✓" : "✗")")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(last.ok ? Theme.work : Theme.bad)
} else {
Text("none yet")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
}
if let last, !last.ok {
Text([last.reason ?? "Status \(last.status)", last.hint].compactMap(\.self).joined(separator: ". "))
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.top, 2)
}
}
Button(sending ? "Sending" : "Send test") { sendTest() }
.buttonStyle(PillButtonStyle(fill: Theme.you))
.disabled(sending)
.padding(.top, 4)
if let result {
VStack(spacing: 2) {
if result.ok {
Text("Apple accepted it at \(result.at.formatted(.dateTime.hour().minute().second()))")
.foregroundStyle(Theme.work)
} else {
Text(result.reason ?? "Status \(result.status)")
.foregroundStyle(Theme.bad)
if let hint = result.hint {
Text(hint)
.foregroundStyle(Theme.secondaryText)
}
}
}
.font(.system(size: 11))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
if let error {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
HStack {
Text("Gateway")
.font(.system(size: 12, weight: .semibold))
Spacer(minLength: 4)
Text(model.gatewayHost ?? "demo")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.padding(.horizontal, 8)
.padding(.top, 8)
Button("Unpair") { confirmingUnpair = true }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: Theme.bad))
}
private func sendTest() {
sending = true
result = nil
error = nil
Task {
do {
result = try await model.api.testPush()
reload += 1
} catch {
self.error = model.message(for: error)
}
sending = false
}
}
}
/// Loads the status when it appears, and again whenever `reload` changes, then shows `content`.
/// The last status stays on screen while a reload runs.
private struct StatusLoader<Content: View>: View {
@Environment(AppModel.self) private var model
var reload: Int
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
var scrolls: Bool
var content: (StatusResponse) -> Content
@State private var status: StatusResponse?
@State private var error: String?
/// `status` starts the screen loaded, for snapshots and previews.
init(
reload: Int = 0, status: StatusResponse? = nil, scrolls: Bool = true,
@ViewBuilder content: @escaping (StatusResponse) -> Content
) {
self.reload = reload
self.scrolls = scrolls
self.content = content
_status = State(initialValue: status)
}
var body: some View {
if scrolls {
ScrollView { column }
.task(id: reload) { await load() }
} else {
column
}
}
private var column: some View {
VStack(alignment: .leading, spacing: 6) {
if let status {
content(status)
} else if error == nil {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 20)
}
if let error {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 4)
if status == nil {
Button("Try again") { Task { await load() } }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
}
}
}
.padding(.horizontal, 2)
}
private func load() async {
do {
status = try await model.api.status()
error = nil
} catch {
self.error = model.message(for: error)
}
}
}
/// A bold line and an optional grey one under it, as on mock C's issue cards.
private struct RowText: View {
var title: String
var detail: String?
var body: some View {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
if let detail {
Text(detail)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
#Preview("Status") {
NavigationStack {
StatusView()
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Environment") {
NavigationStack {
HostStatusView(hostId: "laptop")
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Notifications") {
NavigationStack {
NotificationsView()
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
+22 -1
View File
@@ -1,4 +1,5 @@
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done.
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done,
// and red means broken.
import SwiftUI
@@ -10,6 +11,7 @@ enum Theme {
static let control = Color(red: 0.173, green: 0.173, blue: 0.180) // #2c2c2e
static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e
}
extension Phase {
@@ -87,6 +89,25 @@ enum Format {
return "\(hours / 24) d"
}
/// How long until a date, such as "40 s", "3 min" or "2 h".
static func countdown(to date: Date, now: Date = .now) -> String {
let seconds = max(0, Int(date.timeIntervalSince(now).rounded()))
if seconds < 60 { return "\(seconds) s" }
if seconds < 3600 { return "\(seconds / 60) min" }
return "\(seconds / 3600) h"
}
/// "expires in 29 days", "expires today" or "expired 2 days ago", for a token.
static func expiry(_ date: Date, now: Date = .now) -> String {
let days = Int((date.timeIntervalSince(now) / 86_400).rounded(.down))
if date < now {
let ago = Int(now.timeIntervalSince(date) / 86_400)
return ago < 1 ? "expired today" : "expired \(ago) day\(ago == 1 ? "" : "s") ago"
}
if days < 1 { return "expires today" }
return "expires in \(days) day\(days == 1 ? "" : "s")"
}
/// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5".
static func modelName(_ id: String) -> String {
var words: [String] = []