548 lines
19 KiB
Swift
548 lines
19 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)
|
|
}
|
|
LoadStatus(loaded: status != nil, error: error, retry: load)
|
|
}
|
|
.padding(.horizontal, 2)
|
|
}
|
|
|
|
private func load() async {
|
|
do {
|
|
status = try await model.api.status()
|
|
error = nil
|
|
} catch {
|
|
if let message = model.message(for: error) { self.error = message }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|