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.
507 lines
18 KiB
Swift
507 lines
18 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 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)
|
|
}
|