import SwiftUI struct SetupView: View { @Environment(Settings.self) private var settings @Environment(MeshDataStore.self) private var store @Environment(\.dismiss) private var dismiss @State private var host: String = "" @State private var portText: String = "8080" @State private var token: String = "" @State private var useTLS: Bool = false @State private var testing = false @State private var testResult: String? @State private var testOK = false let isInitial: Bool var body: some View { NavigationStack { Form { if !isInitial { statusSection } Section("Server") { TextField("Host or IP", text: $host) .textContentType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) TextField("Port", text: $portText) .keyboardType(.numberPad) Toggle("Use HTTPS", isOn: $useTLS) } Section("Authentication") { SecureField("Bearer token", text: $token) .textContentType(.password) .autocorrectionDisabled() .textInputAutocapitalization(.never) Link(destination: URL(string: "https://meshmonitor.org/development/api.html")!) { Label("How to create a token", systemImage: "questionmark.circle") .font(.footnote) } } Section { Button { Task { await testConnection() } } label: { HStack { Text("Test connection") Spacer() if testing { ProgressView() } } } .disabled(testing || host.trimmingCharacters(in: .whitespaces).isEmpty) if let r = testResult { Label(r, systemImage: testOK ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") .foregroundStyle(testOK ? .green : .red) .font(.footnote) } } Section { Text("Configure your MeshMonitor server URL and a Bearer token. Token is stored in the iOS Keychain.") .font(.footnote) .foregroundStyle(.secondary) } Section { Link(destination: URL(string: "https://git.andros.dev/andros/ios-meshmonitor-chat")!) { Label("Source code", systemImage: "chevron.left.forwardslash.chevron.right") } Link(destination: URL(string: "https://git.andros.dev/andros/contribute")!) { Label("Report a bug", systemImage: "ant") } } } .navigationTitle(isInitial ? "Connect" : "Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { if !isInitial { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } .disabled(!canSave) } } .onAppear(perform: load) } } @ViewBuilder private var statusSection: some View { Section("Status") { if let s = store.status { row("Server", value: "Reachable", valueColor: .green) row("Mesh node", value: meshStateLabel(s.meshState), valueColor: meshStateColor(s.meshState)) if let v = s.version { row("Version", value: v) } if let name = s.localNodeName { let id = s.localNodeId.map { " (\($0))" } ?? "" row("Node", value: "\(name)\(id)") } if let up = s.uptime { row("Uptime", value: formatUptime(up)) } if let stats = s.statistics { if let n = stats.nodes { row("Nodes", value: "\(n)") } if let m = stats.messages { row("Messages", value: "\(m)") } if let c = stats.channels { row("Channels", value: "\(c)") } } } else if store.loading { HStack { Text("Status").foregroundStyle(.secondary); Spacer(); ProgressView() } } else if let err = store.error { row("Server", value: "Unreachable", valueColor: .red) Text(err).font(.footnote).foregroundStyle(.red) } else { Text("No data yet.").foregroundStyle(.secondary) } } } private func row(_ key: String, value: String, valueColor: Color? = nil) -> some View { HStack { Text(key).foregroundStyle(.secondary) Spacer() Text(value) .foregroundStyle(valueColor ?? .primary) .multilineTextAlignment(.trailing) } } private func meshStateLabel(_ state: ServerStatus.MeshState) -> String { switch state { case .online: return "Online" case .idle: return "Online (idle)" case .offline: return "Offline" } } private func meshStateColor(_ state: ServerStatus.MeshState) -> Color { switch state { case .online: return .green case .idle: return .orange case .offline: return .red } } private func formatUptime(_ secs: Double) -> String { let total = Int(secs) let days = total / 86400 let hours = (total % 86400) / 3600 let mins = (total % 3600) / 60 if days > 0 { return "\(days)d \(hours)h" } if hours > 0 { return "\(hours)h \(mins)m" } return "\(mins)m" } private var canSave: Bool { !host.trimmingCharacters(in: .whitespaces).isEmpty && !token.trimmingCharacters(in: .whitespaces).isEmpty && (Int(portText) ?? 0) > 0 } private func load() { host = settings.host portText = String(settings.port) useTLS = settings.useTLS token = settings.token } private func save() { settings.host = host.trimmingCharacters(in: .whitespaces) settings.port = Int(portText) ?? 8080 settings.useTLS = useTLS settings.token = token.trimmingCharacters(in: .whitespaces) dismiss() } @MainActor private func testConnection() async { testing = true defer { testing = false } testResult = nil let probe = ServerConfig( host: host.trimmingCharacters(in: .whitespaces), port: Int(portText) ?? 8080, useTLS: useTLS, token: token.trimmingCharacters(in: .whitespaces) ) guard probe.isConfigured else { testOK = false testResult = "Host and token are required." return } let client = APIClient(settings: settings, config: probe) do { let channels = try await client.fetchChannels() let status = try? await client.fetchStatus() testOK = true let nodeBit = status?.localNodeName.map { " — \($0)" } ?? "" testResult = "Connected, \(channels.count) channel\(channels.count == 1 ? "" : "s")\(nodeBit)" } catch APIError.unauthorized { testOK = false testResult = "Invalid token (unauthorized)." } catch { testOK = false testResult = error.localizedDescription } } } #Preview { SetupView(isInitial: true) .environment(Settings()) .environment(MeshDataStore()) }