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) } }