Add cellular data toggle, Tailscale remote access, fix ToggleRow padding

- ToggleRow: fix cramped layout — proper horizontal + vertical card padding
- ConnectionStore: add allowCellularBackup, useTailscaleWhenRemote, tailscaleHost
- LANMonitor: add isOnCellular (NWPath cellular interface), isTailscaleActive
  (detects utun interface with 100.x.x.x CGNAT address), openTailscaleApp()
- BackupEngine: gate backup on cellular setting; resolveHost() switches to
  tailscaleHost when not on trusted LAN and Tailscale tunnel is active
- SettingsView: CONNECTIVITY section (cellular toggle + live status),
  REMOTE ACCESS section (Tailscale toggle, host field, status dot, Open button)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-05-16 11:40:07 +03:00
parent 07b5e45c78
commit 03d63e60de
5 changed files with 211 additions and 26 deletions

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
var body: some View {
ZStack {
@@ -12,6 +13,8 @@ struct SettingsView: View {
LANTriggerSection()
mediaSection
quietHoursSection
connectivitySection
remoteAccessSection
notificationSection
}
.padding(.horizontal, AppTheme.hPad)
@@ -100,6 +103,123 @@ struct SettingsView: View {
.padding(.vertical, 12)
}
// MARK: Connectivity
private var connectivitySection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("CONNECTIVITY")
VStack(spacing: 0) {
ToggleRow(
icon: "antenna.radiowaves.left.and.right",
title: "Allow over cellular",
subtitle: "Uses mobile data when Wi-Fi is unavailable",
isOn: $store.allowCellularBackup
)
// Live status chip
if lanMonitor.isOnCellular {
hairline
HStack(spacing: 8) {
Circle()
.fill(store.allowCellularBackup ? AppTheme.positive : AppTheme.destructive)
.frame(width: 6, height: 6)
Text(store.allowCellularBackup
? "On cellular — backup allowed"
: "On cellular — backup blocked")
.font(AppTheme.micro(11))
.foregroundStyle(store.allowCellularBackup
? AppTheme.positive : AppTheme.destructive)
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 10)
}
}
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.isOnCellular)
}
}
// MARK: Remote Access (Tailscale)
private var remoteAccessSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("REMOTE ACCESS")
VStack(spacing: 0) {
ToggleRow(
icon: "network.badge.shield.half.filled",
title: "Use Tailscale when remote",
subtitle: "Connects via VPN when not on home network",
isOn: $store.useTailscaleWhenRemote
)
if store.useTailscaleWhenRemote {
hairline
// Tailscale host input
HStack(spacing: 12) {
Image(systemName: "server.rack")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 3) {
Text("Tailscale NAS host")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
TextField("100.x.x.x or nas.tail-xxxxx.ts.net",
text: $store.tailscaleHost)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
hairline
// Tailscale status + open button
HStack(spacing: 10) {
Circle()
.fill(lanMonitor.isTailscaleActive ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 7, height: 7)
Text(lanMonitor.isTailscaleActive ? "Tailscale connected" : "Tailscale not active")
.font(AppTheme.caption())
.foregroundStyle(lanMonitor.isTailscaleActive
? AppTheme.positive : AppTheme.inkTertiary)
Spacer()
if !lanMonitor.isTailscaleActive {
Button {
lanMonitor.openTailscaleApp()
} label: {
Text("Open Tailscale")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.ink)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
}
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
}
}
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.useTailscaleWhenRemote)
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: lanMonitor.isTailscaleActive)
}
}
// MARK: Notifications
private var notificationSection: some View {