Files
wristcode/watch/WristCode/Views/StatusView.swift
T

561 lines
20 KiB
Swift

// 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 switch per kind of push, 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, which pushes it wants, 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
/// The switches as last set here, ahead of the next status load.
@State private var prefs: NotificationPrefs?
@State private var prefsError: String?
@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)
}
}
if let current = prefs ?? device.notifications {
Text("Notify me when")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
ForEach(NotificationPrefs.Key.allCases, id: \.self) { key in
SwitchCard(title: Self.label(key), isOn: Binding(
get: { current[key] },
set: { set(key, $0, from: current) }))
}
Text("Host problems always notify.")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
if let prefsError {
Text(prefsError)
.font(.system(size: 11))
.foregroundStyle(Theme.bad)
.padding(.horizontal, 6)
}
}
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 static func label(_ key: NotificationPrefs.Key) -> String {
switch key {
case .needsMe: "Needs me"
case .finished: "Finished"
case .failed: "Failed"
case .subagents: "Subagents ask"
}
}
/// Flips the switch at once and sends just that key. If the gateway refuses, it flips back and
/// a line under the switches shows why.
private func set(_ key: NotificationPrefs.Key, _ on: Bool, from current: NotificationPrefs) {
var next = current
next[key] = on
prefs = next
prefsError = nil
Task {
do {
prefs = try await model.api.setNotification(key, on)
} catch {
prefs?[key] = !on
prefsError = model.message(for: error)
}
}
}
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)
}