feat: Sync tab, hamburger menu, network status, browse UX

- Replace History tab with Sync tab (file list, NAS status, free-up-space retention)
- Add AppMenuView hamburger sheet (History, Errors, Connection Status, Sync Activity)
- BackupView: Kisani logo chip in toolbar, hamburger menu button, swipeable ring
  pages (progress/pending/failed/uploaded), always-visible compact stats strip
- SettingsView: NETWORK section (SSID, NAS reachability, trusted-network warning,
  refresh button)
- BrowseView: pull-to-refresh, long-press context menu on items
- LANMonitor: nasReachable, nasCheckTime, checkNASReachability(host:port:)
- ConnectionStore: localRetentionDays (0/60/90/365 days)
- Regenerate xcodeproj with xcodegen to include new files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-05-16 17:58:00 +03:00
parent e1e851360b
commit 15f9da843c
9 changed files with 1129 additions and 53 deletions

View File

@@ -13,6 +13,7 @@ struct SettingsView: View {
VStack(spacing: AppTheme.sectionGap) {
appearanceSection
LANTriggerSection()
networkStatusSection
mediaSection
quietHoursSection
connectivitySection
@@ -155,6 +156,144 @@ struct SettingsView: View {
.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 {