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 { struct BackupView: View {
@EnvironmentObject var engine: BackupEngine @EnvironmentObject var engine: BackupEngine
@EnvironmentObject var store: ConnectionStore @EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@StateObject private var vm = BackupViewModel() @StateObject private var vm = BackupViewModel()
@State private var showMenu = false
@State private var ringPage = 0
private let ringPageCount = 4
var body: some View { var body: some View {
ZStack { ZStack {
AppTheme.background.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
@@ -15,27 +21,26 @@ struct BackupView: View {
// Ring hero // Ring hero
ringHero ringHero
.padding(.bottom, 28) .padding(.bottom, 24)
// Completion summary (only on .completed) // Compact stats strip (always visible)
if case .completed = engine.job.status { statsStrip
completionStrip .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 28) .padding(.bottom, 20)
.transition(.opacity.combined(with: .scale(scale: 0.97))) .transition(.opacity.combined(with: .scale(scale: 0.97)))
}
Spacer(minLength: 0) Spacer(minLength: 0)
// NAS status // NAS status
nasStatusRow nasStatusRow
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16) .padding(.bottom, 12)
// Photos access nudge // Photos access nudge
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited { if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
photosAccessRow photosAccessRow
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16) .padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .bottom))) .transition(.opacity.combined(with: .move(edge: .bottom)))
} }
@@ -45,11 +50,48 @@ struct BackupView: View {
.padding(.bottom, 32) .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: engine.job.status == .completed)
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized) .animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
.task { vm.loadPhotoCount(filter: store.backupFilter) } .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 // MARK: Ring hero
private var ringHero: some View { private var ringHero: some View {
@@ -64,22 +106,79 @@ struct BackupView: View {
.accessibilityLabel("Backup progress") .accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(engine.job.progress * 100)) percent") .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) { VStack(spacing: 4) {
Text(percentText) Text(percentText)
.font(.system(size: 44, weight: .semibold, design: .rounded)) .font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit() .monospacedDigit()
ringStatusLabel
}
ringSubtitle case 1: // Pending
.transition(.opacity) 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 @ViewBuilder
private var ringSubtitle: some View { private var ringStatusLabel: some View {
switch engine.job.status { switch engine.job.status {
case .running: case .running:
VStack(spacing: 2) { 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 { private var ringColor: Color {
switch engine.job.status { switch engine.job.status {
case .completed: return AppTheme.positive case .completed: return AppTheme.positive
@@ -131,45 +243,49 @@ struct BackupView: View {
private var percentText: String { "\(Int(engine.job.progress * 100))%" } private var percentText: String { "\(Int(engine.job.progress * 100))%" }
// MARK: Completion strip // MARK: Compact stats strip
private var completionStrip: some View { private var statsStrip: some View {
HStack(spacing: 0) { VStack(spacing: 8) {
statCell("\(engine.job.uploadedFiles)", label: "uploaded") HStack(spacing: 0) {
thinDivider compactStat("\(engine.job.uploadedFiles)", label: "up", icon: "arrow.up.circle.fill", color: AppTheme.positive)
statCell("\(engine.job.skippedFiles)", label: "skipped") thinDivider
thinDivider compactStat("\(engine.job.skippedFiles)", label: "skip", icon: "minus.circle.fill", color: AppTheme.inkQuaternary)
statCell( thinDivider
"\(engine.job.failedFiles)", compactStat("\(engine.job.failedFiles)", label: "err", icon: "xmark.circle.fill",
label: "errors", color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkQuaternary)
valueColor: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink 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 { private func compactStat(_ value: String, label: String, icon: String, color: Color) -> some View {
VStack(spacing: 3) { HStack(spacing: 5) {
Image(systemName: icon)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(color.opacity(0.7))
Text(value) Text(value)
.font(.system(size: 17, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundStyle(valueColor) .foregroundStyle(AppTheme.ink)
.monospacedDigit() .monospacedDigit()
Text(label) Text(label)
.font(AppTheme.micro()) .font(.system(size: 10, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 14) .padding(.vertical, 10)
} }
private var thinDivider: some View { private var thinDivider: some View {
Rectangle() Rectangle()
.fill(AppTheme.separator) .fill(AppTheme.separator)
.frame(width: 0.5) .frame(width: 0.5)
.padding(.vertical, 10) .padding(.vertical, 8)
} }
// MARK: NAS status row // MARK: NAS status row
@@ -187,12 +303,20 @@ struct BackupView: View {
if let conn = store.savedConnection { if let conn = store.savedConnection {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text(conn.host) Text(conn.displayName)
.font(AppTheme.headline()) .font(AppTheme.headline())
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
Text(conn.nasProtocol.rawValue) HStack(spacing: 4) {
.font(AppTheme.micro(10)) Text(conn.nasProtocol.rawValue)
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
if let ssid = lanMonitor.currentSSID {
Text("·")
.foregroundStyle(AppTheme.inkQuaternary)
Text(ssid)
.foregroundStyle(AppTheme.inkTertiary)
}
}
.font(AppTheme.micro(10))
} }
} else { } else {
Text("No NAS configured") Text("No NAS configured")
@@ -202,13 +326,20 @@ struct BackupView: View {
Spacer() Spacer()
HStack(spacing: 5) { VStack(alignment: .trailing, spacing: 2) {
Circle() HStack(spacing: 4) {
.fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary) Circle()
.frame(width: 6, height: 6) .fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
Text(engine.job.status.isActive ? "Active" : "Ready") .frame(width: 6, height: 6)
.font(AppTheme.micro()) Text(engine.job.status.isActive ? "Active" : "Ready")
.foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary) .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) .padding(.horizontal, 14)

View File

@@ -78,6 +78,7 @@ struct BrowseView: View {
} }
} }
.task { await loadItems(path: currentPath) } .task { await loadItems(path: currentPath) }
.refreshable { await loadItems(path: currentPath) }
} }
// MARK: Sub-views // MARK: Sub-views
@@ -190,6 +191,31 @@ struct BrowseView: View {
selectedPath = item.path selectedPath = item.path
} }
} }
.contextMenu {
if item.isDirectory {
Button {
navigate(to: item.path)
} label: {
Label("Open Folder", systemImage: "folder")
}
Button {
selectedPath = item.path
} label: {
Label("Select This Folder", systemImage: "checkmark.circle")
}
} else {
Button {
// Placeholder: Make available offline
} label: {
Label("Make Available Offline", systemImage: "arrow.down.circle")
}
}
Button(role: .destructive) {
// Placeholder: context-sensitive action
} label: {
Label("More Info", systemImage: "info.circle")
}
}
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)

View File

@@ -27,8 +27,8 @@ struct iPhoneTabLayout: View {
NavigationStack { BackupView() } NavigationStack { BackupView() }
.tabItem { Label("Backup", systemImage: "arrow.up.doc.fill") } .tabItem { Label("Backup", systemImage: "arrow.up.doc.fill") }
NavigationStack { HistoryView() } NavigationStack { SyncView() }
.tabItem { Label("History", systemImage: "clock.arrow.circlepath") } .tabItem { Label("Sync", systemImage: "arrow.triangle.2.circlepath") }
NavigationStack { NavigationStack {
if let conn = ConnectionStore.shared.savedConnection { if let conn = ConnectionStore.shared.savedConnection {
@@ -51,15 +51,15 @@ struct iPadSidebarLayout: View {
NavigationSplitView { NavigationSplitView {
List(selection: $selection) { List(selection: $selection) {
Label("Backup", systemImage: "arrow.up.doc.fill").tag("backup") Label("Backup", systemImage: "arrow.up.doc.fill").tag("backup")
Label("History", systemImage: "clock.arrow.circlepath").tag("history") Label("Sync", systemImage: "arrow.triangle.2.circlepath").tag("sync")
Label("Browse", systemImage: "folder.fill").tag("browse") Label("Browse", systemImage: "folder.fill").tag("browse")
Label("Settings", systemImage: "gearshape.fill").tag("settings") Label("Settings", systemImage: "gearshape.fill").tag("settings")
} }
.navigationTitle("NASBackup") .navigationTitle("Kisani")
} detail: { } detail: {
switch selection { switch selection {
case "backup": BackupView() case "backup": BackupView()
case "history": HistoryView() case "sync": SyncView()
case "browse": case "browse":
if let conn = ConnectionStore.shared.savedConnection { if let conn = ConnectionStore.shared.savedConnection {
BrowseView(connection: conn, selectedPath: .constant("/")) BrowseView(connection: conn, selectedPath: .constant("/"))

View File

@@ -13,6 +13,7 @@ struct SettingsView: View {
VStack(spacing: AppTheme.sectionGap) { VStack(spacing: AppTheme.sectionGap) {
appearanceSection appearanceSection
LANTriggerSection() LANTriggerSection()
networkStatusSection
mediaSection mediaSection
quietHoursSection quietHoursSection
connectivitySection connectivitySection
@@ -155,6 +156,144 @@ struct SettingsView: View {
.padding(.vertical, 12) .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 // MARK: Connectivity
private var connectivitySection: some View { private var connectivitySection: some View {

View File

@@ -0,0 +1,394 @@
import SwiftUI
struct SyncView: View {
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@State private var files: [NASItem] = []
@State private var isLoading = false
@State private var error: String? = nil
@State private var appeared = false
private var connection: NASConnection? { store.savedConnection }
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
if connection == nil {
noConnectionState
} else if isLoading && files.isEmpty {
loadingState
} else if let err = error {
errorState(err)
} else if files.isEmpty && !isLoading {
emptyState
} else {
fileList
}
}
.navigationTitle("Sync")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
nasStatusChip
}
}
.task { await loadFiles() }
.refreshable { await loadFiles() }
}
// MARK: States
private var noConnectionState: some View {
VStack(spacing: 16) {
Image(systemName: "externaldrive.badge.xmark")
.font(.system(size: 36, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary)
VStack(spacing: 6) {
Text("No NAS connected")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
Text("Connect to a NAS to see synced files")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
private var loadingState: some View {
VStack(spacing: 12) {
ProgressView().tint(AppTheme.inkTertiary)
Text("Loading files…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
private func errorState(_ message: String) -> some View {
VStack(spacing: 20) {
ZStack {
Circle().fill(AppTheme.surfaceSunken).frame(width: 64, height: 64)
Image(systemName: "wifi.exclamationmark")
.font(.system(size: 24, weight: .light))
.foregroundStyle(AppTheme.inkSecondary)
}
VStack(spacing: 6) {
Text("Couldn't load files")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AppTheme.ink)
Text(message)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
Button(action: { Task { await loadFiles() } }) {
HStack(spacing: 5) {
Image(systemName: "arrow.clockwise").font(.system(size: 12, weight: .medium))
Text("Retry").font(.system(size: 13, weight: .medium))
}
.foregroundStyle(AppTheme.ink)
.padding(.horizontal, 16).padding(.vertical, 8)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
}
}
private var emptyState: some View {
VStack(spacing: 14) {
Image(systemName: "checkmark.circle")
.font(.system(size: 32, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary)
VStack(spacing: 5) {
Text("Nothing backed up yet")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
if let conn = connection {
Text(conn.remotePath)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
}
// MARK: File list
private var fileList: some View {
ScrollView {
LazyVStack(spacing: 0) {
// Header strip
headerStrip
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 14)
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.horizontal, AppTheme.hPad)
ForEach(Array(files.enumerated()), id: \.element.id) { idx, file in
SyncFileRow(item: file)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 6)
.animation(
.spring(response: 0.38, dampingFraction: 0.82)
.delay(Double(min(idx, 12)) * 0.03),
value: appeared
)
if idx < files.count - 1 {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, 60)
}
}
// Free Up Space section
freeUpSpaceSection
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 28)
.padding(.bottom, 48)
}
}
.onAppear { appeared = true }
}
private var headerStrip: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("\(files.count) files backed up")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppTheme.ink)
if let conn = connection {
Text(conn.remotePath)
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
.lineLimit(1)
}
}
Spacer()
HStack(spacing: 5) {
Circle()
.fill(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 6, height: 6)
Text(lanMonitor.nasReachable == true ? "Available" : "Offline")
.font(AppTheme.micro())
.foregroundStyle(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkTertiary)
}
}
}
// MARK: Free Up Space
private var freeUpSpaceSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("FREE UP SPACE")
VStack(spacing: 0) {
// Explanation
HStack(spacing: 12) {
Image(systemName: "iphone.and.arrow.forward")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text("Delete local copies after backup")
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Text("Photos and videos already on your NAS")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, AppTheme.cardPad)
// Retention picker
HStack {
Text("Delete after")
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Spacer()
Menu {
Button("Never (keep all)") { store.localRetentionDays = 0 }
Button("After 60 days") { store.localRetentionDays = 60 }
Button("After 90 days") { store.localRetentionDays = 90 }
Button("After 1 year") { store.localRetentionDays = 365 }
} label: {
HStack(spacing: 4) {
Text(retentionLabel)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
if store.localRetentionDays > 0 {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, AppTheme.cardPad)
HStack(spacing: 8) {
Image(systemName: "info.circle")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
Text("Files will be removed from this device \(retentionLabel.lowercased()) they were successfully backed up. They remain on your NAS.")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
.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: store.localRetentionDays > 0)
}
}
private var retentionLabel: String {
switch store.localRetentionDays {
case 0: return "Never"
case 60: return "60 days"
case 90: return "90 days"
case 365: return "1 year"
default: return "\(store.localRetentionDays) days"
}
}
// MARK: Toolbar chip
private var nasStatusChip: some View {
HStack(spacing: 5) {
Circle()
.fill(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 5, height: 5)
Text(connection?.displayName ?? "NAS")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(AppTheme.surfaceSunken)
.clipShape(Capsule(style: .continuous))
}
// MARK: Data
private func loadFiles() async {
guard let conn = connection else { return }
isLoading = true
error = nil
do {
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
try await s.connect(to: conn.host, port: conn.port,
username: conn.username, password: conn.password)
files = try await s.listDirectory(at: conn.remotePath)
.filter { !$0.isDirectory }
.sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) }
s.disconnect()
} catch let e as BackupError {
self.error = e.errorDescription
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
// MARK: File Row
struct SyncFileRow: View {
let item: NASItem
private var ext: String { (item.name as NSString).pathExtension.lowercased() }
private var fileIcon: String {
switch ext {
case "jpg", "jpeg", "png", "heic", "heif", "gif", "webp": return "photo"
case "mp4", "mov", "m4v", "avi": return "play.rectangle"
case "raw", "dng", "cr2", "nef", "arw": return "camera.aperture"
case "pdf": return "doc.richtext"
default: return "doc"
}
}
private var iconColor: Color {
switch ext {
case "jpg", "jpeg", "png", "heic", "heif", "gif", "webp": return AppTheme.interactive
case "mp4", "mov", "m4v", "avi": return AppTheme.destructive
case "raw", "dng", "cr2", "nef", "arw": return AppTheme.inkSecondary
default: return AppTheme.inkTertiary
}
}
var body: some View {
HStack(spacing: 12) {
// File type icon
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(iconColor.opacity(0.1))
.frame(width: 38, height: 38)
Image(systemName: fileIcon)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(iconColor)
}
// Name + meta
VStack(alignment: .leading, spacing: 3) {
Text(item.name)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.ink)
.lineLimit(1)
HStack(spacing: 6) {
if let date = item.modifiedDate {
Text(date, format: .dateTime.month(.abbreviated).day().year())
.foregroundStyle(AppTheme.inkTertiary)
}
if item.size > 0 {
Text("·").foregroundStyle(AppTheme.inkQuaternary)
Text(formatBytes(item.size))
.foregroundStyle(AppTheme.inkTertiary)
}
}
.font(.system(size: 11, weight: .regular))
}
Spacer()
// Synced badge
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(AppTheme.positive.opacity(0.7))
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 11)
}
private func formatBytes(_ bytes: Int64) -> String {
let gb = Double(bytes) / 1_073_741_824
if gb >= 1 { return String(format: "%.1f GB", gb) }
let mb = Double(bytes) / 1_048_576
if mb >= 1 { return String(format: "%.0f MB", mb) }
return String(format: "%.0f KB", Double(bytes) / 1024)
}
}

View File

@@ -10,6 +10,7 @@
10062AA91BC883AE597443BD /* URL+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277DF2E5F4E566C869EC29F3 /* URL+.swift */; }; 10062AA91BC883AE597443BD /* URL+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277DF2E5F4E566C869EC29F3 /* URL+.swift */; };
13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */; }; 13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */; };
30693CB8EE711577978EF88C /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */; }; 30693CB8EE711577978EF88C /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */; };
32A4A6A2CFB6C810A8C2CE0B /* AppMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67C33F6CACACB9DD50FE3F40 /* AppMenuView.swift */; };
36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */; }; 36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */; };
37D808079BDEF32D04847D04 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09160B7B7A06BD900D0D87A /* MainTabView.swift */; }; 37D808079BDEF32D04847D04 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09160B7B7A06BD900D0D87A /* MainTabView.swift */; };
4148416D31C8AC65FEE6394F /* PHAsset+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */; }; 4148416D31C8AC65FEE6394F /* PHAsset+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */; };
@@ -42,6 +43,7 @@
B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C955FEF6710E3667C7A73A02 /* ProgressRing.swift */; }; B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C955FEF6710E3667C7A73A02 /* ProgressRing.swift */; };
B7DBF1420838AD3397A280B5 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55EA454B783C2AEDABFB00A7 /* BackupView.swift */; }; B7DBF1420838AD3397A280B5 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55EA454B783C2AEDABFB00A7 /* BackupView.swift */; };
BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B67584BB6D705BCB3D40192 /* SFTPService.swift */; }; BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B67584BB6D705BCB3D40192 /* SFTPService.swift */; };
BEBDBED4A7D1D786C47DB8EE /* SyncView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A1E7D60A4D778026CC8007F /* SyncView.swift */; };
C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */; }; C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */; };
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */; }; C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */; };
CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */; }; CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */; };
@@ -85,6 +87,8 @@
55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = "<group>"; }; 55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = "<group>"; };
5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = "<group>"; }; 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = "<group>"; };
5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; }; 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; };
67C33F6CACACB9DD50FE3F40 /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
6A1E7D60A4D778026CC8007F /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; }; 6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = "<group>"; }; 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = "<group>"; };
7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = "<group>"; }; 7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = "<group>"; };
@@ -143,6 +147,7 @@
FDF5327105186A6456CF0B08 /* Login */, FDF5327105186A6456CF0B08 /* Login */,
611BB6A8B01D82432C0A32EC /* Onboarding */, 611BB6A8B01D82432C0A32EC /* Onboarding */,
147B04CA2B7A7723CCD13E93 /* Settings */, 147B04CA2B7A7723CCD13E93 /* Settings */,
DFE38DFA4C8F1E08CBCC8626 /* Sync */,
); );
path = Features; path = Features;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -233,6 +238,7 @@
803154EA96E2431367806BAF /* Backup */ = { 803154EA96E2431367806BAF /* Backup */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
67C33F6CACACB9DD50FE3F40 /* AppMenuView.swift */,
55EA454B783C2AEDABFB00A7 /* BackupView.swift */, 55EA454B783C2AEDABFB00A7 /* BackupView.swift */,
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */, AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */,
); );
@@ -336,6 +342,14 @@
path = Tests; path = Tests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
DFE38DFA4C8F1E08CBCC8626 /* Sync */ = {
isa = PBXGroup;
children = (
6A1E7D60A4D778026CC8007F /* SyncView.swift */,
);
path = Sync;
sourceTree = "<group>";
};
F75092ACBEF36F32816506F7 /* Models */ = { F75092ACBEF36F32816506F7 /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -469,6 +483,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */, 36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */,
32A4A6A2CFB6C810A8C2CE0B /* AppMenuView.swift in Sources */,
9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */, 9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */,
84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */, 84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */,
8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */, 8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */,
@@ -505,6 +520,7 @@
13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */, 13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */,
A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */, A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */,
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */, C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */,
BEBDBED4A7D1D786C47DB8EE /* SyncView.swift in Sources */,
B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */, B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */,
96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */, 96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */,
10062AA91BC883AE597443BD /* URL+.swift in Sources */, 10062AA91BC883AE597443BD /* URL+.swift in Sources */,

View File

@@ -13,6 +13,8 @@ final class LANMonitor: ObservableObject {
@Published private(set) var currentSSID: String? = nil @Published private(set) var currentSSID: String? = nil
@Published private(set) var isOnCellular: Bool = false @Published private(set) var isOnCellular: Bool = false
@Published private(set) var isTailscaleActive: Bool = false @Published private(set) var isTailscaleActive: Bool = false
@Published private(set) var nasReachable: Bool? = nil
@Published private(set) var nasCheckTime: Date? = nil
private let monitor = NWPathMonitor() private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.albert.nasbackup.lan", qos: .utility) private let queue = DispatchQueue(label: "com.albert.nasbackup.lan", qos: .utility)
@@ -67,7 +69,7 @@ final class LANMonitor: ObservableObject {
UIApplication.shared.open(url) UIApplication.shared.open(url)
} }
private func checkSSID() async { func checkSSID() async {
currentSSID = await fetchCurrentSSID() currentSSID = await fetchCurrentSSID()
} }
@@ -79,6 +81,42 @@ final class LANMonitor: ObservableObject {
} }
} }
func checkNASReachability(host: String, port: Int) async {
let ep = NWEndpoint.hostPort(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: UInt16(port)) ?? 445
)
let connection = NWConnection(to: ep, using: .tcp)
var done = false
let reachable = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
connection.stateUpdateHandler = { state in
guard !done else { return }
switch state {
case .ready:
done = true
connection.cancel()
cont.resume(returning: true)
case .failed, .waiting:
done = true
connection.cancel()
cont.resume(returning: false)
default: break
}
}
connection.start(queue: queue)
DispatchQueue.global().asyncAfter(deadline: .now() + 4) {
guard !done else { return }
done = true
connection.cancel()
cont.resume(returning: false)
}
}
nasReachable = reachable
nasCheckTime = Date()
}
func checkAndTriggerIfTrusted(trustedSSIDs: [String]) async { func checkAndTriggerIfTrusted(trustedSSIDs: [String]) async {
let ssid = await fetchCurrentSSID() let ssid = await fetchCurrentSSID()
guard let ssid, trustedSSIDs.contains(ssid) else { return } guard let ssid, trustedSSIDs.contains(ssid) else { return }

View File

@@ -58,6 +58,11 @@ final class ConnectionStore: ObservableObject {
didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: "appearanceMode") } didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: "appearanceMode") }
} }
// Local retention: 0 = never delete, 60/90/365 = days after backup
@Published var localRetentionDays: Int {
didSet { UserDefaults.standard.set(localRetentionDays, forKey: "localRetentionDays") }
}
// Cellular & remote access // Cellular & remote access
@Published var allowCellularBackup: Bool { @Published var allowCellularBackup: Bool {
didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") } didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") }
@@ -83,6 +88,7 @@ final class ConnectionStore: ObservableObject {
quietHoursEnd = UserDefaults.standard.object(forKey: "quietHoursEnd") as? Int ?? 7 quietHoursEnd = UserDefaults.standard.object(forKey: "quietHoursEnd") as? Int ?? 7
onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown") onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown")
appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system
localRetentionDays = UserDefaults.standard.object(forKey: "localRetentionDays") as? Int ?? 0
allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false
useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false
tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? "" tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""