// ConnectivityView.swift // Jarvis — server address, a Remote Access card (Use when remote + // host + live status), and an Auto-connect automation card. The app can't start // the VPN tunnel (iOS), so when the remote is down we offer a one-tap jump. import SwiftUI struct ConnectivityView: View { @EnvironmentObject var connectivity: ConnectivitySettings @EnvironmentObject var manager: ConnectivityManager @Environment(\.dismiss) private var dismiss private var directReach: Reachability { manager.status[connectivity.directHost.trimmingCharacters(in: .whitespaces)] ?? .unknown } private var remoteReach: Reachability { manager.status[connectivity.remoteHost.trimmingCharacters(in: .whitespaces)] ?? .unknown } private var providerName: String { connectivity.provider.displayName } var body: some View { ZStack { Color.black.ignoresSafeArea() ScrollView { VStack(alignment: .leading, spacing: 26) { header serverSection remoteAccessSection automationSection providerSection actions } .padding(.horizontal, 20) .padding(.vertical, 12) } } .preferredColorScheme(.dark) .presentationDragIndicator(.visible) .task { await recheck() } } // MARK: - Header private var header: some View { HStack { Text("Connectivity") .font(.system(size: 22, weight: .heavy)) .foregroundStyle(.white) Spacer() Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: 15, weight: .bold)) .foregroundStyle(Color(hex: "888888")) .frame(width: 44, height: 44) } } .padding(.top, 8) } // MARK: - Server (LAN) private var serverSection: some View { VStack(alignment: .leading, spacing: 10) { sectionLabel("SERVER") Card { cardRow(icon: "server.rack") { fieldStack(title: "Direct (LAN) host", text: $connectivity.directHost, placeholder: "192.168.30.50:8080", reach: directReach) } } } } // MARK: - Remote access private var remoteAccessSection: some View { VStack(alignment: .leading, spacing: 10) { sectionLabel("REMOTE ACCESS") Card { VStack(spacing: 0) { cardRow(icon: "globe.badge.chevron.backward") { Toggle(isOn: $connectivity.remoteEnabled) { VStack(alignment: .leading, spacing: 3) { Text("Use \(providerName) when remote") .font(.system(size: 16, weight: .heavy)) .foregroundStyle(.white) Text("Connects via VPN when not on home network") .font(.system(size: 12)) .foregroundStyle(Color(hex: "888888")) .fixedSize(horizontal: false, vertical: true) } } .tint(Palette.orange) } if connectivity.remoteEnabled { divider cardRow(icon: "externaldrive.connected.to.line.below") { fieldStack(title: "\(providerName) host", text: $connectivity.remoteHost, placeholder: connectivity.provider.remoteHint, reach: remoteReach) } divider statusRow } } } } } private var statusRow: some View { HStack(spacing: 10) { dot(for: connectivity.remoteEnabled ? remoteReach : .unknown) Text(statusText) .font(.system(size: 14, weight: .bold)) .foregroundStyle(statusColor) Spacer() if remoteReach != .reachable && connectivity.provider.canOpenApp { Button { connectivity.provider.openApp() } label: { Text("Open \(providerName)") .font(.system(size: 13, weight: .bold)) .foregroundStyle(.black) .padding(.horizontal, 14) .frame(height: 36) .background(Palette.orange) .clipShape(Capsule()) } } } .padding(.vertical, 12) .padding(.horizontal, 14) } private var statusText: String { switch remoteReach { case .reachable: return "\(providerName) connected" case .checking: return "Checking \(providerName)…" default: return "\(providerName) not active" } } private var statusColor: Color { switch remoteReach { case .reachable: return Color(hex: "33C25E") case .checking: return Palette.healthFailing default: return Color(hex: "888888") } } // MARK: - Automation private var automationSection: some View { VStack(alignment: .leading, spacing: 10) { sectionLabel("AUTOMATION") Card { VStack(spacing: 0) { cardRow(icon: "wifi") { Toggle(isOn: $connectivity.autoConnect) { VStack(alignment: .leading, spacing: 3) { Text("Auto-connect on network change") .font(.system(size: 16, weight: .heavy)) .foregroundStyle(.white) Text("Prefers the LAN on a trusted network; switches to \(providerName) when away") .font(.system(size: 12)) .foregroundStyle(Color(hex: "888888")) .fixedSize(horizontal: false, vertical: true) } } .tint(Palette.orange) } if connectivity.autoConnect { divider cardRow(icon: "timer") { HStack { Text("Wait before switching") .font(.system(size: 15, weight: .bold)) .foregroundStyle(.white) Spacer() Stepper(value: $connectivity.switchDelaySeconds, in: 1...60, step: 1) { Text("\(connectivity.switchDelaySeconds)s") .font(.system(size: 14, weight: .bold, design: .monospaced)) .foregroundStyle(Palette.orange) } .labelsHidden() .fixedSize() Text("\(connectivity.switchDelaySeconds)s") .font(.system(size: 14, weight: .bold, design: .monospaced)) .foregroundStyle(Palette.orange) .frame(width: 36, alignment: .trailing) } } } } } Text("iOS can't start the \(providerName) tunnel for Jarvis — keep the \(providerName) app connected when away. Jarvis only picks the reachable address.") .font(.system(size: 11)) .foregroundStyle(Color(hex: "666666")) .lineSpacing(2) } } // MARK: - Provider private var providerSection: some View { VStack(alignment: .leading, spacing: 10) { sectionLabel("PROVIDER") ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(VPNProvider.allCases) { p in let selected = connectivity.provider == p Button { connectivity.provider = p } label: { Text(p.displayName) .font(.system(size: 13, weight: .bold)) .foregroundStyle(selected ? .black : Color(hex: "AAAAAA")) .padding(.horizontal, 14) .frame(height: 34) .background(selected ? Palette.orange : Palette.surface) .clipShape(Capsule()) } } } } } } // MARK: - Actions private var actions: some View { HStack(spacing: 12) { Button { Task { await recheck() } } label: { Text("Re-check") .font(.system(size: 15, weight: .bold)) .foregroundStyle(.white) .frame(maxWidth: .infinity).frame(height: 50) .background(Palette.surface2) .clipShape(RoundedRectangle(cornerRadius: 14)) } Button { Task { await manager.resolveAndActivate(); await recheck() } } label: { Text("Apply") .font(.system(size: 15, weight: .bold)) .foregroundStyle(.black) .frame(maxWidth: .infinity).frame(height: 50) .background(connectivity.isConfigured ? Palette.orange : Palette.surface2) .clipShape(RoundedRectangle(cornerRadius: 14)) } .disabled(!connectivity.isConfigured) } } // MARK: - Building blocks private func sectionLabel(_ t: String) -> some View { Text(t) .font(.system(size: 11, weight: .bold, design: .monospaced)) .kerning(0.8) .foregroundStyle(Color(hex: "555555")) } private func Card(@ViewBuilder _ content: () -> Content) -> some View { content() .background(Palette.surface) .clipShape(RoundedRectangle(cornerRadius: 14)) } private func cardRow(icon: String, @ViewBuilder _ content: () -> Content) -> some View { HStack(alignment: .center, spacing: 14) { Image(systemName: icon) .font(.system(size: 18, weight: .regular)) .foregroundStyle(Color(hex: "999999")) .frame(width: 26) content() } .padding(.horizontal, 14) .padding(.vertical, 14) .frame(minHeight: 44) } private var divider: some View { Rectangle().fill(Palette.surface2).frame(height: 1).padding(.leading, 54) } private func fieldStack(title: String, text: Binding, placeholder: String, reach: Reachability) -> some View { VStack(alignment: .leading, spacing: 3) { Text(title) .font(.system(size: 12)) .foregroundStyle(Color(hex: "888888")) HStack(spacing: 8) { TextField("", text: text, prompt: Text(placeholder).foregroundColor(Color(hex: "444444"))) .font(.system(size: 16, weight: .regular, design: .monospaced)) .foregroundStyle(Palette.orange) .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.URL) if reach != .unknown { dot(for: reach) } } } } // MARK: - Reachability helpers private func recheck() async { await manager.check(connectivity.directHost) if connectivity.remoteActiveInPolicy { await manager.check(connectivity.remoteHost) } } private func color(for r: Reachability) -> Color { switch r { case .reachable: return Color(hex: "33C25E") case .unreachable: return Color(hex: "C25555") case .checking: return Palette.healthFailing case .unknown: return Color(hex: "555555") } } private func dot(for r: Reachability) -> some View { Circle().fill(color(for: r)).frame(width: 9, height: 9) } }