diff --git a/Features/Backup/AppMenuView.swift b/Features/Backup/AppMenuView.swift new file mode 100644 index 0000000..086b0a4 --- /dev/null +++ b/Features/Backup/AppMenuView.swift @@ -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) + } +} diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 24ed423..ceddbd7 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -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.. 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) diff --git a/Features/Browse/BrowseView.swift b/Features/Browse/BrowseView.swift index d411719..1acd34d 100644 --- a/Features/Browse/BrowseView.swift +++ b/Features/Browse/BrowseView.swift @@ -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) diff --git a/Features/MainTabView.swift b/Features/MainTabView.swift index b750fb4..13a8842 100644 --- a/Features/MainTabView.swift +++ b/Features/MainTabView.swift @@ -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("/")) diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index f7fb087..f6e1ff6 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -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 { diff --git a/Features/Sync/SyncView.swift b/Features/Sync/SyncView.swift new file mode 100644 index 0000000..1c06a6c --- /dev/null +++ b/Features/Sync/SyncView.swift @@ -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) + } +} diff --git a/NASBackup.xcodeproj/project.pbxproj b/NASBackup.xcodeproj/project.pbxproj index 5314304..2e3e970 100644 --- a/NASBackup.xcodeproj/project.pbxproj +++ b/NASBackup.xcodeproj/project.pbxproj @@ -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 = ""; }; 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = ""; }; 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = ""; }; + 67C33F6CACACB9DD50FE3F40 /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = ""; }; + 6A1E7D60A4D778026CC8007F /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = ""; }; 6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = ""; }; 7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = ""; }; @@ -143,6 +147,7 @@ FDF5327105186A6456CF0B08 /* Login */, 611BB6A8B01D82432C0A32EC /* Onboarding */, 147B04CA2B7A7723CCD13E93 /* Settings */, + DFE38DFA4C8F1E08CBCC8626 /* Sync */, ); path = Features; sourceTree = ""; @@ -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 = ""; }; + DFE38DFA4C8F1E08CBCC8626 /* Sync */ = { + isa = PBXGroup; + children = ( + 6A1E7D60A4D778026CC8007F /* SyncView.swift */, + ); + path = Sync; + sourceTree = ""; + }; 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 */, diff --git a/Services/LANMonitor.swift b/Services/LANMonitor.swift index 443c467..6713893 100644 --- a/Services/LANMonitor.swift +++ b/Services/LANMonitor.swift @@ -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) 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 } diff --git a/Shared/Persistence/ConnectionStore.swift b/Shared/Persistence/ConnectionStore.swift index 7659799..9c467b0 100644 --- a/Shared/Persistence/ConnectionStore.swift +++ b/Shared/Persistence/ConnectionStore.swift @@ -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") ?? ""