import SwiftUI struct SyncView: View { @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor @EnvironmentObject var engine: BackupEngine @EnvironmentObject var statusService: BackupStatusService private var connection: NASConnection? { store.savedConnection } private var queueItems: [BackupQueueItem] { engine.queueItems } private var uploadingItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploading } } private var queuedItems: [BackupQueueItem] { queueItems.filter { $0.status == .queued } } private var completedItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploaded || $0.status == .skipped } } private var failedItems: [BackupQueueItem] { queueItems.filter { $0.status == .failed } } var body: some View { ZStack { AppTheme.background.ignoresSafeArea() if connection == nil { noConnectionState } else { contentList } } .navigationTitle("Sync") .navigationBarTitleDisplayMode(.inline) } // MARK: — Main content private var contentList: some View { ScrollView { LazyVStack(spacing: 0, pinnedViews: []) { nasHeader .padding(.horizontal, AppTheme.hPad) .padding(.vertical, 14) Divider() .padding(.horizontal, AppTheme.hPad) if queueItems.isEmpty { idleState .padding(.top, 48) } else { queueContent } freeUpSpaceSection .padding(.horizontal, AppTheme.hPad) .padding(.top, 32) .padding(.bottom, 48) } } .refreshable { // Pull-to-refresh: show latest queue from engine (already live) } } // MARK: — NAS header (no pill, no border) private var nasHeader: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 3) { if let conn = connection { Text(conn.host) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(AppTheme.ink) Text(conn.remotePath) .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) .lineLimit(1) Text(conn.nasProtocol == .smb ? "SMB" : "SFTP") .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } } Spacer() HStack(spacing: 5) { Circle() .fill(nasColor) .frame(width: 6, height: 6) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable) Text(nasLabel) .font(.system(size: 12, weight: .medium)) .foregroundStyle(nasColor) } } } private var nasColor: Color { switch lanMonitor.nasReachable { case true: AppTheme.positive case false: AppTheme.destructive.opacity(0.8) case nil: AppTheme.inkTertiary } } private var nasLabel: String { switch lanMonitor.nasReachable { case true: "Online" case false: "Offline" case nil: "Checking…" } } // MARK: — Queue content @ViewBuilder private var queueContent: some View { // Active / uploading if !uploadingItems.isEmpty || !queuedItems.isEmpty { sectionHeader("ACTIVE", count: uploadingItems.count + queuedItems.count) ForEach(uploadingItems + queuedItems) { item in QueueFileRow(item: item) Divider().padding(.leading, 58) } } // Failed if !failedItems.isEmpty { sectionHeader("FAILED", count: failedItems.count) ForEach(failedItems) { item in QueueFileRow(item: item) Divider().padding(.leading, 58) } } // Completed if !completedItems.isEmpty { sectionHeader("COMPLETED", count: completedItems.count) ForEach(completedItems.prefix(30)) { item in QueueFileRow(item: item) Divider().padding(.leading, 58) } } } private func sectionHeader(_ title: String, count: Int) -> some View { HStack { Text(title) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(AppTheme.inkTertiary) .kerning(0.8) Spacer() Text("\(count)") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, AppTheme.hPad) .padding(.top, 20) .padding(.bottom, 6) } // MARK: — States private var idleState: some View { VStack(spacing: 14) { Image(systemName: statusService.snapshot.alreadySafe > 0 ? "checkmark.circle" : "arrow.up.doc") .font(.system(size: 32, weight: .ultraLight)) .foregroundStyle(AppTheme.inkQuaternary) VStack(spacing: 5) { Text(statusService.snapshot.alreadySafe > 0 ? "All caught up" : "No backup run yet") .font(.system(size: 15, weight: .medium)) .foregroundStyle(AppTheme.inkSecondary) if statusService.snapshot.nasArchiveTotal > 0 { Text("\(statusService.snapshot.nasArchiveTotal) files on NAS") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } } } } 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 sync activity") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } } } // MARK: — Free Up Space private var freeUpSpaceSection: some View { VStack(alignment: .leading, spacing: 8) { Text("FREE UP SPACE") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(AppTheme.inkTertiary) .kerning(0.8) .padding(.horizontal, 2) VStack(spacing: 0) { HStack(spacing: 12) { Image(systemName: "iphone.and.arrow.forward") .font(.system(size: 14)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { Text("Delete local copies after backup") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Text("Files already on your NAS") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() } .padding(AppTheme.cardPad) Divider().padding(.leading, AppTheme.cardPad) HStack { Text("Delete after") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() Menu { Button("Never") { store.localRetentionDays = 0 } Button("60 days") { store.localRetentionDays = 60 } Button("90 days") { store.localRetentionDays = 90 } Button("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(AppTheme.cardPad) } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } private var retentionLabel: String { switch store.localRetentionDays { case 0: "Never" case 60: "60 days" case 90: "90 days" case 365: "1 year" default: "\(store.localRetentionDays) days" } } } // MARK: — Per-file row struct QueueFileRow: View { let item: BackupQueueItem private var ext: String { (item.filename as NSString).pathExtension.lowercased() } private var fileIcon: String { switch ext { case "jpg","jpeg","png","heic","heif","gif","webp": "photo" case "mp4","mov","m4v","avi": "play.rectangle" case "raw","dng","cr2","nef","arw": "camera.aperture" case "pdf": "doc.richtext" default: "doc" } } private var iconColor: Color { switch ext { case "jpg","jpeg","png","heic","heif","gif","webp": AppTheme.interactive case "mp4","mov","m4v","avi": AppTheme.destructive case "raw","dng","cr2","nef","arw": AppTheme.inkSecondary default: AppTheme.inkTertiary } } var body: some View { VStack(spacing: 0) { HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(iconColor.opacity(0.1)) .frame(width: 38, height: 38) Image(systemName: fileIcon) .font(.system(size: 14, weight: .regular)) .foregroundStyle(iconColor) } VStack(alignment: .leading, spacing: 3) { Text(item.filename) .font(.system(size: 13, weight: .regular)) .foregroundStyle(AppTheme.ink) .lineLimit(1) statusRow } Spacer() statusBadge } .padding(.horizontal, AppTheme.hPad) .padding(.vertical, 10) // Progress bar for uploading if item.status == .uploading && item.totalBytes > 0 { GeometryReader { geo in ZStack(alignment: .leading) { Rectangle() .fill(AppTheme.surfaceSunken) .frame(height: 2) Rectangle() .fill(AppTheme.interactive) .frame(width: geo.size.width * CGFloat(item.progress), height: 2) .animation(.linear(duration: 0.1), value: item.progress) } } .frame(height: 2) .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 8) } } } @ViewBuilder private var statusRow: some View { switch item.status { case .uploading: if item.totalBytes > 0 { HStack(spacing: 4) { Text("\(formatBytes(item.bytesUploaded)) / \(formatBytes(item.totalBytes))") if item.speedBytesPerSec > 0 { Text("·") Text("\(formatBytes(Int64(item.speedBytesPerSec)))/s") } } .font(.system(size: 11, weight: .regular)) .foregroundStyle(AppTheme.inkTertiary) } else { Text("Uploading…") .font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary) } case .queued: Text("Queued") .font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary) case .uploaded, .skipped: if let date = item.completedAt { Text(date, format: .dateTime.month(.abbreviated).day().hour().minute()) .font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary) } case .failed: Text(item.error?.message ?? "Upload failed") .font(.system(size: 11)) .foregroundStyle(AppTheme.destructive.opacity(0.85)) .lineLimit(1) } } @ViewBuilder private var statusBadge: some View { switch item.status { case .uploading: ProgressView() .scaleEffect(0.7) .tint(AppTheme.interactive) case .queued: Image(systemName: "clock") .font(.system(size: 13)) .foregroundStyle(AppTheme.inkQuaternary) case .uploaded: Image(systemName: "checkmark.circle.fill") .font(.system(size: 15)) .foregroundStyle(AppTheme.positive.opacity(0.8)) case .skipped: Image(systemName: "arrow.forward.circle") .font(.system(size: 15)) .foregroundStyle(AppTheme.inkTertiary) case .failed: Image(systemName: "xmark.circle.fill") .font(.system(size: 15)) .foregroundStyle(AppTheme.destructive.opacity(0.8)) } } private func formatBytes(_ bytes: Int64) -> String { let mb = Double(bytes) / 1_048_576 if mb < 1 { return "\(max(0, bytes / 1024)) KB" } if mb < 1000 { return String(format: "%.1f MB", mb) } return String(format: "%.1f GB", mb / 1024) } }