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:
326
Features/Backup/AppMenuView.swift
Normal file
326
Features/Backup/AppMenuView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -78,6 +78,7 @@ struct BrowseView: View {
|
||||
}
|
||||
}
|
||||
.task { await loadItems(path: currentPath) }
|
||||
.refreshable { await loadItems(path: currentPath) }
|
||||
}
|
||||
|
||||
// MARK: — Sub-views
|
||||
@@ -190,6 +191,31 @@ struct BrowseView: View {
|
||||
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())
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
@@ -27,8 +27,8 @@ struct iPhoneTabLayout: View {
|
||||
NavigationStack { BackupView() }
|
||||
.tabItem { Label("Backup", systemImage: "arrow.up.doc.fill") }
|
||||
|
||||
NavigationStack { HistoryView() }
|
||||
.tabItem { Label("History", systemImage: "clock.arrow.circlepath") }
|
||||
NavigationStack { SyncView() }
|
||||
.tabItem { Label("Sync", systemImage: "arrow.triangle.2.circlepath") }
|
||||
|
||||
NavigationStack {
|
||||
if let conn = ConnectionStore.shared.savedConnection {
|
||||
@@ -51,15 +51,15 @@ struct iPadSidebarLayout: View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selection) {
|
||||
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("Settings", systemImage: "gearshape.fill").tag("settings")
|
||||
}
|
||||
.navigationTitle("NASBackup")
|
||||
.navigationTitle("Kisani")
|
||||
} detail: {
|
||||
switch selection {
|
||||
case "backup": BackupView()
|
||||
case "history": HistoryView()
|
||||
case "sync": SyncView()
|
||||
case "browse":
|
||||
if let conn = ConnectionStore.shared.savedConnection {
|
||||
BrowseView(connection: conn, selectedPath: .constant("/"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
394
Features/Sync/SyncView.swift
Normal file
394
Features/Sync/SyncView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
10062AA91BC883AE597443BD /* URL+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277DF2E5F4E566C869EC29F3 /* URL+.swift */; };
|
||||
13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D1C00B125D5CC000988DD0 /* SavedConnections.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 */; };
|
||||
37D808079BDEF32D04847D04 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09160B7B7A06BD900D0D87A /* MainTabView.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 */; };
|
||||
B7DBF1420838AD3397A280B5 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55EA454B783C2AEDABFB00A7 /* BackupView.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 */; };
|
||||
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -143,6 +147,7 @@
|
||||
FDF5327105186A6456CF0B08 /* Login */,
|
||||
611BB6A8B01D82432C0A32EC /* Onboarding */,
|
||||
147B04CA2B7A7723CCD13E93 /* Settings */,
|
||||
DFE38DFA4C8F1E08CBCC8626 /* Sync */,
|
||||
);
|
||||
path = Features;
|
||||
sourceTree = "<group>";
|
||||
@@ -233,6 +238,7 @@
|
||||
803154EA96E2431367806BAF /* Backup */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
67C33F6CACACB9DD50FE3F40 /* AppMenuView.swift */,
|
||||
55EA454B783C2AEDABFB00A7 /* BackupView.swift */,
|
||||
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */,
|
||||
);
|
||||
@@ -336,6 +342,14 @@
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DFE38DFA4C8F1E08CBCC8626 /* Sync */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6A1E7D60A4D778026CC8007F /* SyncView.swift */,
|
||||
);
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F75092ACBEF36F32816506F7 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -469,6 +483,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */,
|
||||
32A4A6A2CFB6C810A8C2CE0B /* AppMenuView.swift in Sources */,
|
||||
9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */,
|
||||
84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */,
|
||||
8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */,
|
||||
@@ -505,6 +520,7 @@
|
||||
13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */,
|
||||
A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */,
|
||||
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */,
|
||||
BEBDBED4A7D1D786C47DB8EE /* SyncView.swift in Sources */,
|
||||
B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */,
|
||||
96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */,
|
||||
10062AA91BC883AE597443BD /* URL+.swift in Sources */,
|
||||
|
||||
@@ -13,6 +13,8 @@ final class LANMonitor: ObservableObject {
|
||||
@Published private(set) var currentSSID: String? = nil
|
||||
@Published private(set) var isOnCellular: 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 queue = DispatchQueue(label: "com.albert.nasbackup.lan", qos: .utility)
|
||||
@@ -67,7 +69,7 @@ final class LANMonitor: ObservableObject {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func checkSSID() async {
|
||||
func checkSSID() async {
|
||||
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 {
|
||||
let ssid = await fetchCurrentSSID()
|
||||
guard let ssid, trustedSSIDs.contains(ssid) else { return }
|
||||
|
||||
@@ -58,6 +58,11 @@ final class ConnectionStore: ObservableObject {
|
||||
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
|
||||
@Published var allowCellularBackup: Bool {
|
||||
didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") }
|
||||
@@ -83,6 +88,7 @@ final class ConnectionStore: ObservableObject {
|
||||
quietHoursEnd = UserDefaults.standard.object(forKey: "quietHoursEnd") as? Int ?? 7
|
||||
onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown")
|
||||
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
|
||||
useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false
|
||||
tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""
|
||||
|
||||
Reference in New Issue
Block a user