import SwiftUI struct SettingsView: View { @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor @State private var showSignOutConfirm = false var body: some View { ZStack { AppTheme.background.ignoresSafeArea() ScrollView { VStack(spacing: AppTheme.sectionGap) { appearanceSection autoBackupSection LANTriggerSection() networkStatusSection mediaSection quietHoursSection connectivitySection remoteAccessSection notificationSection signOutSection } .padding(.horizontal, AppTheme.hPad) .padding(.top, 20) .padding(.bottom, 48) } } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .confirmationDialog( "Sign out of Kisani?", isPresented: $showSignOutConfirm, titleVisibility: .visible ) { Button("Sign Out", role: .destructive) { store.lock() } Button("Sign Out & Forget NAS", role: .destructive) { store.signOut() } Button("Cancel", role: .cancel) {} } message: { Text("\"Sign Out\" keeps your NAS saved and requires Face ID next time. \"Sign Out & Forget NAS\" removes everything from this device.") } } // MARK: — Auto Backup private var autoBackupSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("AUTO BACKUP") VStack(spacing: 0) { ToggleRow( icon: "arrow.clockwise.icloud", title: "Auto backup", subtitle: "Back up new photos automatically when the app opens", isOn: $store.autoBackupEnabled ) } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } // MARK: — Appearance private var appearanceSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("APPEARANCE") HStack(spacing: 0) { ForEach(AppearanceMode.allCases, id: \.self) { mode in Button { withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) { store.appearanceMode = mode } } label: { let selected = store.appearanceMode == mode HStack(spacing: 5) { Image(systemName: mode.icon) .font(.system(size: 12, weight: selected ? .semibold : .regular)) Text(mode.label) .font(.system(size: 12, weight: selected ? .semibold : .regular)) } .foregroundStyle(selected ? AppTheme.ink : AppTheme.inkTertiary) .frame(maxWidth: .infinity) .padding(.vertical, 9) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(selected ? AppTheme.surfaceRaised : Color.clear) .shadow(color: selected ? .black.opacity(0.08) : .clear, radius: 4, x: 0, y: 1) ) } .buttonStyle(PlainButtonStyle()) } } .padding(3) .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) } } // MARK: — Media private var mediaSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("WHAT TO BACK UP") VStack(spacing: 0) { ToggleRow(icon: "photo", title: "Photos", isOn: $store.backupFilter.includePhotos) hairline ToggleRow(icon: "video", title: "Videos", isOn: $store.backupFilter.includeVideos) hairline ToggleRow(icon: "camera.viewfinder", title: "Screenshots", isOn: $store.backupFilter.includeScreenshots) hairline ToggleRow(icon: "rays", title: "RAW", isOn: $store.backupFilter.includeRAW) hairline ToggleRow(icon: "doc.badge.clock", title: "New files only", subtitle: "Skip files already on NAS", isOn: $store.backupFilter.newFilesOnly) } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } // MARK: — Quiet hours private var quietHoursSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("QUIET HOURS") VStack(spacing: 0) { ToggleRow(icon: "moon", title: "Quiet hours", subtitle: "No auto-backup during this window", isOn: $store.quietHoursEnabled) if store.quietHoursEnabled { hairline timePickerRow(label: "Start", hour: $store.quietHoursStart) hairline timePickerRow(label: "End", hour: $store.quietHoursEnd) } hairline ToggleRow(icon: "bolt", title: "Only while charging", subtitle: "Preserve battery on large libraries", isOn: $store.chargingOnlyMode) } .background(AppTheme.surfaceRaised) .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.quietHoursEnabled) } } private func timePickerRow(label: String, hour: Binding) -> some View { HStack { Text(label) .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() Picker(label, selection: hour) { ForEach(0..<24, id: \.self) { h in Text(String(format: "%02d:00", h)).tag(h) } } .pickerStyle(.menu) .tint(AppTheme.inkSecondary) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 12) } // MARK: — Network Status @State private var isCheckingNetwork = false private var networkStatusSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("NETWORK") VStack(spacing: 0) { // Wi-Fi / SSID row HStack(spacing: 12) { Image(systemName: "wifi") .font(.system(size: 14, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { Text(lanMonitor.currentSSID ?? (lanMonitor.isOnCellular ? "Cellular" : "No Wi-Fi")) .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Text(lanMonitor.isOnNetwork ? "Connected" : "Not connected") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() if lanMonitor.currentSSID != nil { let trusted = store.trustedSSIDs.contains(lanMonitor.currentSSID ?? "") HStack(spacing: 4) { Circle() .fill(trusted ? AppTheme.positive : AppTheme.inkQuaternary) .frame(width: 5, height: 5) Text(trusted ? "Trusted" : "Unknown") .font(AppTheme.micro()) .foregroundStyle(trusted ? AppTheme.positive : AppTheme.inkTertiary) } } } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 13) hairline // NAS reachability row HStack(spacing: 12) { Image(systemName: "externaldrive.connected.to.line.below") .font(.system(size: 14, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { Text("NAS reachability") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) if let checkTime = lanMonitor.nasCheckTime { Text("Checked \(checkTime, format: .dateTime.hour().minute())") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } else { Text("Not yet checked") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } } Spacer() Group { if let reachable = lanMonitor.nasReachable { HStack(spacing: 4) { Circle() .fill(reachable ? AppTheme.positive : AppTheme.destructive) .frame(width: 5, height: 5) Text(reachable ? "Reachable" : "Offline") .font(AppTheme.micro()) .foregroundStyle(reachable ? AppTheme.positive : AppTheme.destructive) } } else { Text("—") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkQuaternary) } } } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 13) hairline // Warn if not on trusted network but NAS expected if let ssid = lanMonitor.currentSSID, !store.trustedSSIDs.isEmpty, !store.trustedSSIDs.contains(ssid) { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 11, weight: .regular)) .foregroundStyle(AppTheme.destructive.opacity(0.8)) Text("Not on your preferred network — backup may not reach the NAS.") .font(AppTheme.micro()) .foregroundStyle(AppTheme.destructive.opacity(0.7)) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 10) hairline } // Refresh button Button { guard let conn = store.savedConnection else { return } isCheckingNetwork = true Task { await lanMonitor.checkSSID() await lanMonitor.checkNASReachability(host: conn.host, port: conn.port) isCheckingNetwork = false } } label: { HStack(spacing: 6) { if isCheckingNetwork { ProgressView().scaleEffect(0.7).tint(AppTheme.inkSecondary) } else { Image(systemName: "arrow.clockwise") .font(.system(size: 12, weight: .medium)) .foregroundStyle(AppTheme.inkSecondary) } Text(isCheckingNetwork ? "Checking…" : "Refresh network status") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 13) } .buttonStyle(ScaleButtonStyle()) .disabled(isCheckingNetwork) } .background(AppTheme.surfaceRaised) .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.nasReachable != nil) } } // 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(AppTheme.surfaceRaised) .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()) .accessibilityLabel("Open Tailscale app") .accessibilityHint("Opens the Tailscale app to connect the VPN") } } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 12) } } .background(AppTheme.surfaceRaised) .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 { VStack(alignment: .leading, spacing: 8) { sectionLabel("NOTIFICATIONS") VStack(spacing: 0) { notifRow(icon: "bell", title: "On start", value: "Off") hairline notifRow(icon: "checkmark.circle", title: "On complete", value: "On") hairline notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On") } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } private func notifRow(icon: String, title: String, value: String) -> some View { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 14, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) Text(title) .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() Text(value) .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) } private var hairline: some View { Rectangle() .fill(AppTheme.separator) .frame(height: 0.5) .padding(.leading, AppTheme.cardPad) } // MARK: — Sign Out private var signOutSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("ACCOUNT") VStack(spacing: 0) { // App version row HStack(spacing: 12) { Image(systemName: "info.circle") .font(.system(size: 14, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) Text("App version") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() Text("v1.0") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) hairline // Support row Link(destination: URL(string: "mailto:apps@provoc.ug")!) { HStack(spacing: 12) { Image(systemName: "envelope") .font(.system(size: 14, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) Text("Support") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() Text("apps@provoc.ug") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) Image(systemName: "arrow.up.right") .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) } hairline // Sign Out button Button(action: { showSignOutConfirm = true }) { HStack(spacing: 12) { Image(systemName: "rectangle.portrait.and.arrow.right") .font(.system(size: 14, weight: .regular)) .foregroundStyle(AppTheme.destructive) .frame(width: 20) Text("Sign Out") .font(AppTheme.body()) .foregroundStyle(AppTheme.destructive) Spacer() } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) } .buttonStyle(ScaleButtonStyle()) } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } }