import SwiftUI import Photos struct BackupView: View { @EnvironmentObject var engine: BackupEngine @EnvironmentObject var store: ConnectionStore @StateObject private var vm = BackupViewModel() var body: some View { ZStack { Color.white.ignoresSafeArea() ScrollView { VStack(spacing: 24) { // Ring + counts ringSection // NAS status nasStatusRow // Options card optionsCard // Start / pause button actionButton } .padding(.horizontal, 20) .padding(.top, 20) .padding(.bottom, 40) } } .task { vm.loadPhotoCount(filter: store.backupFilter) } } // MARK: Ring private var ringSection: some View { VStack(spacing: 16) { ZStack { ProgressRing( progress: engine.job.progress, lineWidth: 14, size: 200, color: ringColor ) VStack(spacing: 4) { Text(percentText) .font(.system(size: 36, weight: .bold)) .foregroundColor(AppTheme.textPrimary) if case .running = engine.job.status { Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) / \(engine.job.totalFiles)") .font(AppTheme.bodyFont) .foregroundColor(AppTheme.textSecondary) if let eta = engine.job.eta { Text("~\(etaString(eta))") .font(AppTheme.captionFont) .foregroundColor(AppTheme.textTertiary) } } else { Text("\(vm.totalPhotoCount) photos") .font(AppTheme.bodyFont) .foregroundColor(AppTheme.textSecondary) Text("0 backed up") .font(AppTheme.captionFont) .foregroundColor(AppTheme.textTertiary) } } } // Completion summary if case .completed = engine.job.status { completionSummary } // Progress detail row if case .running = engine.job.status { progressDetailRow } } } private var ringColor: Color { switch engine.job.status { case .completed: return AppTheme.green case .failed: return AppTheme.red default: return AppTheme.blue } } private var percentText: String { "\(Int(engine.job.progress * 100))%" } // MARK: Progress detail private var progressDetailRow: some View { VStack(spacing: 8) { ProgressView(value: engine.job.progress) .tint(AppTheme.blue) HStack { Text(engine.job.currentFileName) .font(AppTheme.captionFont) .foregroundColor(AppTheme.textSecondary) .lineLimit(1) .truncationMode(.middle) Spacer() Text(formatSpeed(engine.job.speedBytesPerSecond)) .font(AppTheme.captionFont) .foregroundColor(AppTheme.textTertiary) } } } // MARK: Completion private var completionSummary: some View { HStack(spacing: 24) { statPill(count: engine.job.uploadedFiles, label: "uploaded", color: AppTheme.green) statPill(count: engine.job.skippedFiles, label: "skipped", color: AppTheme.textSecondary) statPill(count: engine.job.failedFiles, label: "errors", color: engine.job.failedFiles > 0 ? AppTheme.red : AppTheme.textTertiary) } } private func statPill(count: Int, label: String, color: Color) -> some View { VStack(spacing: 2) { Text("\(count)") .font(.system(size: 20, weight: .bold)) .foregroundColor(color) Text(label) .font(AppTheme.captionFont) .foregroundColor(AppTheme.textSecondary) } } // MARK: NAS status row private var nasStatusRow: some View { HStack { Image(systemName: "externaldrive.fill") .foregroundColor(AppTheme.blue) if let conn = store.savedConnection { Text("\(conn.host) — \(conn.username)") .font(AppTheme.bodyFont) .foregroundColor(AppTheme.textPrimary) } Spacer() HStack(spacing: 4) { Circle().fill(AppTheme.green).frame(width: 8, height: 8) Text("Live") .font(AppTheme.captionFont) .foregroundColor(AppTheme.green) } } .padding(14) .background(Color(hex: "#F9FAFB")) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppTheme.cardBorder)) } // MARK: Options card private var optionsCard: some View { VStack(spacing: 0) { optionRow(icon: "camera", title: "What to back up") {} Divider().padding(.leading, 44) optionRow(icon: "doc.badge.clock", title: "New files only") {} Divider().padding(.leading, 44) // Photos access row Button(action: { Task { await vm.requestPhotosAccess() } }) { HStack { Image(systemName: "photo.on.rectangle") .foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.blue : AppTheme.red) .frame(width: 28) Text("Photos access") .font(AppTheme.bodyFont) .foregroundColor(AppTheme.textPrimary) Spacer() Text(photosAccessLabel) .font(AppTheme.captionFont) .foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.green : AppTheme.red) Image(systemName: "chevron.right") .font(.system(size: 13)) .foregroundColor(AppTheme.textTertiary) } .padding(.horizontal, 16) .padding(.vertical, 14) } } .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) .overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) } private var photosAccessLabel: String { switch vm.photosAuthStatus { case .authorized: return "Full access" case .limited: return "Limited" case .denied: return "Denied" default: return "Not set" } } private func optionRow(icon: String, title: String, action: @escaping () -> Void) -> some View { Button(action: action) { HStack { Image(systemName: icon) .foregroundColor(AppTheme.blue) .frame(width: 28) Text(title) .font(AppTheme.bodyFont) .foregroundColor(AppTheme.textPrimary) Spacer() Image(systemName: "chevron.right") .font(.system(size: 13)) .foregroundColor(AppTheme.textTertiary) } .padding(.horizontal, 16) .padding(.vertical, 14) } } // MARK: Action button private var actionButton: some View { Group { switch engine.job.status { case .idle, .completed, .failed, .cancelled: Button(action: startBackup) { Text(engine.job.status == .completed ? "Back up again" : "Start backup") .font(.system(size: 17, weight: .semibold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: 52) .background(engine.job.status == .completed ? AppTheme.green : AppTheme.blue) .clipShape(RoundedRectangle(cornerRadius: 14)) } case .running: Button(action: { engine.pause() }) { Text("Pause") .font(.system(size: 17, weight: .semibold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: 52) .background(Color(hex: "#1E3A5F")) .clipShape(RoundedRectangle(cornerRadius: 14)) } case .paused: HStack(spacing: 12) { Button(action: { engine.resume() }) { Text("Resume") .font(.system(size: 17, weight: .semibold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: 52) .background(AppTheme.blue) .clipShape(RoundedRectangle(cornerRadius: 14)) } Button(action: { engine.cancel() }) { Text("Cancel") .font(.system(size: 17, weight: .semibold)) .foregroundColor(AppTheme.red) .frame(height: 52) .frame(width: 90) .overlay(RoundedRectangle(cornerRadius: 14).stroke(AppTheme.red)) } } case .preparing: HStack { ProgressView() Text("Preparing…") .foregroundColor(AppTheme.textSecondary) } .frame(height: 52) } } } private func startBackup() { guard let conn = store.savedConnection else { return } Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) } } private func formatSpeed(_ bps: Double) -> String { let mbps = bps / 1_000_000 return mbps >= 1 ? String(format: "%.1f MB/s", mbps) : String(format: "%.0f KB/s", bps / 1000) } private func etaString(_ seconds: TimeInterval) -> String { let m = Int(seconds) / 60 let s = Int(seconds) % 60 return m > 0 ? "\(m) min" : "\(s)s" } }