Reading experience:
- Body text #CECECE (dark) / #1A1916 (light) — was near-invisible #4A4A4A
- 30pt heavy headline, 17pt body at 8.5pt line-spacing
- Skeleton pulse loading state replaces dark spinner box
- Hero image fades in on load (0.3s ease-in)
- Orange reading progress bar (2pt) below nav bar
- Max reading width 680pt for iPad legibility
Appearance:
- AppearanceMode enum (system/light/dark) in Theme.swift
- @AppStorage("appearanceMode") applied once in JarvisApp root
- Appearance picker (Picker in Menu) in reader toolbar
- Removed .preferredColorScheme(.dark) from all 12 child views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
322 lines
13 KiB
Swift
322 lines
13 KiB
Swift
// ConnectivityView.swift
|
|
// Jarvis — server address, a Remote Access card (Use <provider> 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)
|
|
}
|
|
}
|
|
.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<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
|
content()
|
|
.background(Palette.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
|
|
private func cardRow<Content: View>(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<String>, 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)
|
|
}
|
|
}
|