import SwiftUI import Photos 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 } } @State private var phoneScreenshots: [LocalPhotoIndex.Record] = [] @State private var nasScreenshotNames: [String] = [] @State private var showPhoneCleanup = false @State private var showNASQuarantine = false 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) } .task { await computeScreenshots() } .sheet(isPresented: $showPhoneCleanup, onDismiss: { Task { await computeScreenshots() } }) { if let conn = connection { PhoneScreenshotCleanupSheet(records: phoneScreenshots, connection: conn) } } .sheet(isPresented: $showNASQuarantine, onDismiss: { Task { await computeScreenshots() } }) { if let conn = connection { NASQuarantineSheet(filenames: nasScreenshotNames, connection: conn) } } } // 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) // Auto-delete after backup 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) // Phone screenshot cleanup Button { showPhoneCleanup = true } label: { HStack(spacing: 12) { Image(systemName: "camera.viewfinder") .font(.system(size: 14)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { Text("Clean up screenshots") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Text(phoneScreenshots.isEmpty ? "No backed-up screenshots found" : "\(phoneScreenshots.count) screenshots safe to remove from phone") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() if !phoneScreenshots.isEmpty { Image(systemName: "chevron.right") .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } } .padding(AppTheme.cardPad) } .buttonStyle(.plain) .disabled(phoneScreenshots.isEmpty) .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) // NAS screenshot quarantine Button { showNASQuarantine = true } label: { HStack(spacing: 12) { Image(systemName: "folder.badge.minus") .font(.system(size: 14)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { Text("NAS screenshot quarantine") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Text(nasScreenshotNames.isEmpty ? "No screenshots found on NAS" : "\(nasScreenshotNames.count) screenshots on NAS") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() if !nasScreenshotNames.isEmpty { Image(systemName: "chevron.right") .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } } .padding(AppTheme.cardPad) } .buttonStyle(.plain) .disabled(nasScreenshotNames.isEmpty) .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } private func computeScreenshots() async { await LocalPhotoIndex.shared.loadOrBuild() let allRecords = await LocalPhotoIndex.shared.allRecords() let screenshotRecords = allRecords.filter { $0.isScreenshot } let nasFilenames: [String] if let cached = await NASManifestCache.shared.directoryFilenames { nasFilenames = cached } else if let manifest = await NASManifestCache.shared.manifest { nasFilenames = manifest.entries.map { $0.filename } } else { nasFilenames = [] } let nasLower = Set(nasFilenames.map { $0.lowercased() }) phoneScreenshots = screenshotRecords.filter { nasLower.contains($0.filename.lowercased()) } let screenshotLower = Set(screenshotRecords.map { $0.filename.lowercased() }) nasScreenshotNames = nasFilenames.filter { screenshotLower.contains($0.lowercased()) } } 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) } } // MARK: — Phone Screenshot Cleanup Sheet struct PhoneScreenshotCleanupSheet: View { let records: [LocalPhotoIndex.Record] let connection: NASConnection @Environment(\.dismiss) private var dismiss @State private var isDeleting = false @State private var done = false var body: some View { NavigationStack { List { Section { ForEach(records, id: \.localIdentifier) { record in HStack(spacing: 10) { Image(systemName: "camera.viewfinder") .font(.system(size: 13)) .foregroundStyle(AppTheme.inkTertiary) .frame(width: 22) VStack(alignment: .leading, spacing: 2) { Text(record.filename) .font(.system(size: 13)) .lineLimit(1) if let date = record.creationDate { Text(date, style: .date) .font(.system(size: 11)) .foregroundStyle(AppTheme.inkTertiary) } } } } } header: { Text("\(records.count) screenshots already backed up to NAS") } } .navigationTitle("Phone Screenshots") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } .safeAreaInset(edge: .bottom) { VStack(spacing: 0) { Divider() Button { Task { await deleteAll() } } label: { Group { if isDeleting { ProgressView().tint(.white) } else if done { Label("Deleted", systemImage: "checkmark") } else { Text("Delete \(records.count) Screenshots from Phone") } } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(AppTheme.destructive) .disabled(isDeleting || done) .padding() } .background(AppTheme.background) } } } private func deleteAll() async { isDeleting = true let ids = records.map { $0.localIdentifier } let assets = PHAsset.fetchAssets(withLocalIdentifiers: ids, options: nil) try? await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(assets) } isDeleting = false done = true try? await Task.sleep(nanoseconds: 1_000_000_000) dismiss() } } // MARK: — NAS Quarantine Sheet struct NASQuarantineSheet: View { let filenames: [String] let connection: NASConnection @Environment(\.dismiss) private var dismiss @State private var progress: (done: Int, total: Int) = (0, 0) @State private var isMoving = false @State private var done = false @State private var errorMessage: String? var body: some View { NavigationStack { ScrollView { VStack(spacing: 24) { Image(systemName: "folder.badge.plus") .font(.system(size: 52, weight: .ultraLight)) .foregroundStyle(AppTheme.inkQuaternary) .padding(.top, 40) VStack(spacing: 6) { Text("\(filenames.count) screenshots on NAS") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(AppTheme.ink) Text("Move to a Screenshots/ subfolder in your NAS backup directory.") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) .multilineTextAlignment(.center) .padding(.horizontal, 32) Text(connection.remotePath + "/Screenshots/") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(AppTheme.inkQuaternary) .padding(.top, 2) } if isMoving { VStack(spacing: 8) { ProgressView(value: Double(progress.done), total: Double(max(progress.total, 1))) .padding(.horizontal, 40) Text("\(progress.done) of \(progress.total) moved") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } } if done { Label("All screenshots moved", systemImage: "checkmark.circle.fill") .foregroundStyle(AppTheme.positive) .font(.system(size: 14, weight: .medium)) } if let err = errorMessage { Text(err) .font(AppTheme.caption()) .foregroundStyle(AppTheme.destructive) .multilineTextAlignment(.center) .padding(.horizontal, 32) } } .frame(maxWidth: .infinity) } .navigationTitle("NAS Quarantine") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } } .safeAreaInset(edge: .bottom) { if !done && !isMoving { VStack(spacing: 0) { Divider() Button { Task { await quarantine() } } label: { Text("Move \(filenames.count) Screenshots") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .padding() } .background(AppTheme.background) } } } } private func quarantine() async { isMoving = true errorMessage = nil progress = (0, filenames.count) let conn = connection let destDir = "\(conn.remotePath)/Screenshots" do { let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() try await transfer.connect(to: conn.host, port: conn.port, username: conn.username, password: conn.password) if !(try await transfer.fileExists(at: destDir)) { try await transfer.createDirectory(at: destDir) } for (i, filename) in filenames.enumerated() { let src = "\(conn.remotePath)/\(filename)" let dst = "\(destDir)/\(filename)" if let data = try? await transfer.downloadData(at: src) { try? await transfer.writeData(data, to: dst) try? await transfer.deleteFile(at: src) } progress = (i + 1, filenames.count) } transfer.disconnect() done = true try? await Task.sleep(nanoseconds: 1_500_000_000) dismiss() } catch { errorMessage = error.localizedDescription isMoving = false } } }