// 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: 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) }