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

@@ -0,0 +1,326 @@
import SwiftUI
struct AppMenuView: View {
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@Environment(\.dismiss) private var dismiss
private var errorEntries: [BackupHistoryEntry] {
store.historyEntries.filter { $0.failedCount > 0 }
}
var body: some View {
NavigationStack {
ZStack {
AppTheme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: AppTheme.sectionGap) {
connectionStatusSection
syncActivitySection
historySection
if !errorEntries.isEmpty {
errorsSection
}
}
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 8)
.padding(.bottom, 48)
}
}
.navigationTitle("Activity")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppTheme.ink)
}
}
}
}
// MARK: Connection Status
private var connectionStatusSection: some View {
VStack(alignment: .leading, spacing: 8) {
menuSectionLabel("CONNECTION STATUS")
VStack(spacing: 0) {
// NAS row
HStack(spacing: 12) {
iconBadge("externaldrive", color: AppTheme.inkSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(store.savedConnection?.displayName ?? "No NAS connected")
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
if let conn = store.savedConnection {
Text("\(conn.nasProtocol.rawValue) · \(conn.host)")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
}
Spacer()
statusBadge(
label: lanMonitor.nasReachable == true ? "Reachable" : lanMonitor.nasReachable == false ? "Offline" : "Unknown",
color: lanMonitor.nasReachable == true ? AppTheme.positive : lanMonitor.nasReachable == false ? AppTheme.destructive : AppTheme.inkQuaternary
)
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 14)
menuHairline
// Network row
HStack(spacing: 12) {
iconBadge("wifi", color: AppTheme.inkSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(lanMonitor.currentSSID ?? "No Wi-Fi")
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Text(lanMonitor.isOnCellular ? "Cellular" : lanMonitor.isOnNetwork ? "Wi-Fi connected" : "Offline")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
statusBadge(
label: lanMonitor.isOnNetwork ? "Online" : "Offline",
color: lanMonitor.isOnNetwork ? AppTheme.positive : AppTheme.inkQuaternary
)
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 14)
if lanMonitor.isTailscaleActive {
menuHairline
HStack(spacing: 12) {
iconBadge("network.badge.shield.half.filled", color: AppTheme.interactive)
Text("Tailscale active")
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Spacer()
statusBadge(label: "VPN", color: AppTheme.interactive)
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 14)
}
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}
// MARK: Sync Activity
private var syncActivitySection: some View {
VStack(alignment: .leading, spacing: 8) {
menuSectionLabel("SYNC ACTIVITY")
VStack(spacing: 0) {
let total = store.historyEntries.count
let uploaded = store.historyEntries.reduce(0) { $0 + $1.uploadedCount }
let errors = store.historyEntries.reduce(0) { $0 + $1.failedCount }
activityStat(icon: "clock.arrow.circlepath", label: "Total backups", value: "\(total)")
menuHairline
activityStat(icon: "arrow.up.doc", label: "Files uploaded", value: "\(uploaded)")
if errors > 0 {
menuHairline
activityStat(icon: "exclamationmark.triangle", label: "Total errors", value: "\(errors)", valueColor: AppTheme.destructive)
}
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}
// MARK: History
private var historySection: some View {
VStack(alignment: .leading, spacing: 8) {
menuSectionLabel("HISTORY")
if store.historyEntries.isEmpty {
emptyCard(icon: "clock.arrow.circlepath", text: "No backups yet")
} else {
VStack(spacing: 0) {
ForEach(Array(store.historyEntries.prefix(5).enumerated()), id: \.element.id) { idx, entry in
compactHistoryRow(entry)
if idx < min(store.historyEntries.count, 5) - 1 {
menuHairline
}
}
if store.historyEntries.count > 5 {
menuHairline
Text("+ \(store.historyEntries.count - 5) more")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkQuaternary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 10)
}
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}
}
private func compactHistoryRow(_ entry: BackupHistoryEntry) -> some View {
HStack(spacing: 10) {
Circle()
.fill(entry.failedCount > 0 ? AppTheme.destructive : AppTheme.positive)
.frame(width: 5, height: 5)
VStack(alignment: .leading, spacing: 2) {
Text(entry.date, format: .dateTime.month(.abbreviated).day().year().hour().minute())
.font(AppTheme.caption())
.foregroundStyle(AppTheme.ink)
Text("\(entry.uploadedCount) uploaded · \(entry.skippedCount) skipped")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
if entry.triggeredByLAN {
Text("AUTO")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(AppTheme.inkSecondary)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: 3, style: .continuous)
.stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
)
}
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
}
// MARK: Errors
private var errorsSection: some View {
VStack(alignment: .leading, spacing: 8) {
menuSectionLabel("ERRORS")
VStack(spacing: 0) {
ForEach(Array(errorEntries.prefix(3).enumerated()), id: \.element.id) { idx, entry in
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(AppTheme.destructive)
VStack(alignment: .leading, spacing: 2) {
Text(entry.date, format: .dateTime.month(.abbreviated).day().year())
.font(AppTheme.caption())
.foregroundStyle(AppTheme.ink)
Text("\(entry.failedCount) file\(entry.failedCount == 1 ? "" : "s") failed")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.destructive.opacity(0.8))
}
Spacer()
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
if idx < min(errorEntries.count, 3) - 1 {
menuHairline
}
}
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}
// MARK: Helpers
private func menuSectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(AppTheme.inkQuaternary)
.kerning(0.6)
}
private var menuHairline: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, AppTheme.cardPad)
}
private func iconBadge(_ name: String, color: Color) -> some View {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 32, height: 32)
Image(systemName: name)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(color)
}
}
private func statusBadge(label: String, color: Color) -> some View {
HStack(spacing: 4) {
Circle().fill(color).frame(width: 5, height: 5)
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(color)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.08))
.clipShape(Capsule(style: .continuous))
}
private func activityStat(icon: String, label: String, value: String, valueColor: Color = AppTheme.ink) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
Text(label)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Spacer()
Text(value)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(valueColor)
.monospacedDigit()
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 13)
}
private func emptyCard(icon: String, text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 13, weight: .light))
.foregroundStyle(AppTheme.inkQuaternary)
Text(text)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 16)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}

View File

@@ -4,8 +4,14 @@ import Photos
struct BackupView: View {
@EnvironmentObject var engine: BackupEngine
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@StateObject private var vm = BackupViewModel()
@State private var showMenu = false
@State private var ringPage = 0
private let ringPageCount = 4
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
@@ -15,27 +21,26 @@ struct BackupView: View {
// Ring hero
ringHero
.padding(.bottom, 28)
.padding(.bottom, 24)
// Completion summary (only on .completed)
if case .completed = engine.job.status {
completionStrip
.padding(.bottom, 28)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
}
// Compact stats strip (always visible)
statsStrip
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
Spacer(minLength: 0)
// NAS status
nasStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16)
.padding(.bottom, 12)
// Photos access nudge
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
photosAccessRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
@@ -45,11 +50,48 @@ struct BackupView: View {
.padding(.bottom, 32)
}
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
logoChip
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showMenu = true
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 16, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
}
.accessibilityLabel("Activity menu")
}
}
.sheet(isPresented: $showMenu) {
AppMenuView()
.environmentObject(store)
.environmentObject(lanMonitor)
}
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed)
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
.task { vm.loadPhotoCount(filter: store.backupFilter) }
}
// MARK: Logo chip
private var logoChip: some View {
HStack(spacing: 6) {
Image("nas_logo_clean")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
Text("Kisani")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AppTheme.ink)
}
}
// MARK: Ring hero
private var ringHero: some View {
@@ -64,22 +106,79 @@ struct BackupView: View {
.accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(engine.job.progress * 100)) percent")
VStack(spacing: 4) {
ringCenterContent
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.id(ringPage)
}
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringPage)
}
.gesture(
DragGesture(minimumDistance: 20)
.onEnded { value in
if value.translation.width < 0 {
ringPage = (ringPage + 1) % ringPageCount
} else {
ringPage = (ringPage - 1 + ringPageCount) % ringPageCount
}
}
)
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
@ViewBuilder
private var ringCenterContent: some View {
switch ringPage {
case 0: // Progress / status
VStack(spacing: 4) {
Text(percentText)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
ringStatusLabel
}
ringSubtitle
.transition(.opacity)
case 1: // Pending
VStack(spacing: 4) {
Text("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("pending")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
case 2: // Failed
VStack(spacing: 4) {
Text("\(engine.job.failedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("failed")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
default: // Completed
VStack(spacing: 4) {
Text("\(engine.job.uploadedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.positive)
.contentTransition(.numericText())
.monospacedDigit()
Text("uploaded")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
@ViewBuilder
private var ringSubtitle: some View {
private var ringStatusLabel: some View {
switch engine.job.status {
case .running:
VStack(spacing: 2) {
@@ -121,6 +220,19 @@ struct BackupView: View {
}
}
// MARK: Page indicator dots
private var pageIndicator: some View {
HStack(spacing: 5) {
ForEach(0..<ringPageCount, id: \.self) { i in
Circle()
.fill(i == ringPage ? AppTheme.ink : AppTheme.inkQuaternary)
.frame(width: i == ringPage ? 5 : 4, height: i == ringPage ? 5 : 4)
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: ringPage)
}
}
}
private var ringColor: Color {
switch engine.job.status {
case .completed: return AppTheme.positive
@@ -131,45 +243,49 @@ struct BackupView: View {
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
// MARK: Completion strip
// MARK: Compact stats strip
private var completionStrip: some View {
HStack(spacing: 0) {
statCell("\(engine.job.uploadedFiles)", label: "uploaded")
thinDivider
statCell("\(engine.job.skippedFiles)", label: "skipped")
thinDivider
statCell(
"\(engine.job.failedFiles)",
label: "errors",
valueColor: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink
)
private var statsStrip: some View {
VStack(spacing: 8) {
HStack(spacing: 0) {
compactStat("\(engine.job.uploadedFiles)", label: "up", icon: "arrow.up.circle.fill", color: AppTheme.positive)
thinDivider
compactStat("\(engine.job.skippedFiles)", label: "skip", icon: "minus.circle.fill", color: AppTheme.inkQuaternary)
thinDivider
compactStat("\(engine.job.failedFiles)", label: "err", icon: "xmark.circle.fill",
color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkQuaternary)
thinDivider
compactStat("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))", label: "left", icon: "clock.fill", color: AppTheme.inkSecondary)
}
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
pageIndicator
}
.padding(.horizontal, AppTheme.hPad)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
}
private func statCell(_ value: String, label: String, valueColor: Color = AppTheme.ink) -> some View {
VStack(spacing: 3) {
private func compactStat(_ value: String, label: String, icon: String, color: Color) -> some View {
HStack(spacing: 5) {
Image(systemName: icon)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(color.opacity(0.7))
Text(value)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(valueColor)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AppTheme.ink)
.monospacedDigit()
Text(label)
.font(AppTheme.micro())
.font(.system(size: 10, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.padding(.vertical, 10)
}
private var thinDivider: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(width: 0.5)
.padding(.vertical, 10)
.padding(.vertical, 8)
}
// MARK: NAS status row
@@ -187,12 +303,20 @@ struct BackupView: View {
if let conn = store.savedConnection {
VStack(alignment: .leading, spacing: 1) {
Text(conn.host)
Text(conn.displayName)
.font(AppTheme.headline())
.foregroundStyle(AppTheme.ink)
Text(conn.nasProtocol.rawValue)
.font(AppTheme.micro(10))
.foregroundStyle(AppTheme.inkTertiary)
HStack(spacing: 4) {
Text(conn.nasProtocol.rawValue)
.foregroundStyle(AppTheme.inkTertiary)
if let ssid = lanMonitor.currentSSID {
Text("·")
.foregroundStyle(AppTheme.inkQuaternary)
Text(ssid)
.foregroundStyle(AppTheme.inkTertiary)
}
}
.font(AppTheme.micro(10))
}
} else {
Text("No NAS configured")
@@ -202,13 +326,20 @@ struct BackupView: View {
Spacer()
HStack(spacing: 5) {
Circle()
.fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 6, height: 6)
Text(engine.job.status.isActive ? "Active" : "Ready")
.font(AppTheme.micro())
.foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary)
VStack(alignment: .trailing, spacing: 2) {
HStack(spacing: 4) {
Circle()
.fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 6, height: 6)
Text(engine.job.status.isActive ? "Active" : "Ready")
.font(AppTheme.micro())
.foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary)
}
if let reachable = lanMonitor.nasReachable {
Text(reachable ? "NAS reachable" : "NAS offline")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(reachable ? AppTheme.positive.opacity(0.7) : AppTheme.destructive.opacity(0.7))
}
}
}
.padding(.horizontal, 14)