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: AppTheme.sectionGap) { ringSection nasRow optionsCard actionButton } .padding(.horizontal, AppTheme.hPad) .padding(.top, 24) .padding(.bottom, 48) } } .task { vm.loadPhotoCount(filter: store.backupFilter) } } // MARK: — Ring private var ringSection: some View { VStack(spacing: 20) { ZStack { ProgressRing( progress: engine.job.progress, lineWidth: 8, size: 180, color: ringColor, backgroundColor: Color(hex: "#EBEBEB") ) VStack(spacing: 3) { Text(percentText) .font(.system(size: 40, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) if case .running = engine.job.status { Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkSecondary) if let eta = engine.job.eta { Text("~\(etaString(eta)) remaining") .font(.system(size: 11, weight: .regular)) .foregroundStyle(AppTheme.inkTertiary) } } else { Text("\(vm.totalPhotoCount) photos") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkSecondary) } } } // Completion summary if case .completed = engine.job.status { HStack(spacing: 0) { statCell("\(engine.job.uploadedFiles)", label: "uploaded") dividerLine statCell("\(engine.job.skippedFiles)", label: "skipped") dividerLine statCell("\(engine.job.failedFiles)", label: "errors", color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkTertiary) } .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .transition(.opacity.combined(with: .scale(scale: 0.95))) } // Progress detail if case .running = engine.job.status { VStack(spacing: 8) { GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 2, style: .continuous) .fill(Color(hex: "#EBEBEB")) .frame(height: 3) RoundedRectangle(cornerRadius: 2, style: .continuous) .fill(AppTheme.ink) .frame(width: geo.size.width * engine.job.progress, height: 3) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) } } .frame(height: 3) HStack { Text(engine.job.currentFileName) .font(.system(size: 12, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .lineLimit(1) .truncationMode(.middle) Spacer() Text(formatSpeed(engine.job.speedBytesPerSecond)) .font(.system(size: 12, weight: .regular)) .foregroundStyle(AppTheme.inkTertiary) } } .transition(.opacity) } } .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.status == .completed) } private var ringColor: Color { switch engine.job.status { case .completed: return AppTheme.positive case .failed: return AppTheme.destructive default: return AppTheme.ink } } private var percentText: String { "\(Int(engine.job.progress * 100))%" } private func statCell(_ value: String, label: String, color: Color = AppTheme.ink) -> some View { VStack(spacing: 2) { Text(value) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(color) Text(label) .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } .frame(maxWidth: .infinity) .padding(.vertical, 14) } private var dividerLine: some View { Rectangle() .fill(AppTheme.separator) .frame(width: 0.5) .padding(.vertical, 12) } // MARK: — NAS row private var nasRow: some View { HStack(spacing: 10) { ZStack { Circle() .fill(AppTheme.surfaceSunken) .frame(width: 32, height: 32) Image(systemName: "externaldrive") .font(.system(size: 13, weight: .medium)) .foregroundStyle(AppTheme.inkSecondary) } if let conn = store.savedConnection { VStack(alignment: .leading, spacing: 1) { Text(conn.host) .font(AppTheme.headline()) .foregroundStyle(AppTheme.ink) Text(conn.username) .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkSecondary) } } Spacer() HStack(spacing: 5) { Circle() .fill(AppTheme.positive) .frame(width: 6, height: 6) Text("Live") .font(AppTheme.micro()) .foregroundStyle(AppTheme.positive) } } .padding(14) .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) } // MARK: — Options private var optionsCard: some View { VStack(spacing: 0) { optionRow(icon: "camera", label: "What to back up") {} hairline optionRow(icon: "doc.badge.clock", label: "New files only") {} hairline Button(action: { Task { await vm.requestPhotosAccess() } }) { HStack { Image(systemName: "photo.on.rectangle") .font(.system(size: 14, weight: .regular)) .foregroundStyle(vm.photosAuthStatus == .authorized ? AppTheme.inkSecondary : AppTheme.destructive) .frame(width: 20) Text("Photos access") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() Text(photosLabel) .font(AppTheme.caption()) .foregroundStyle(vm.photosAuthStatus == .authorized ? AppTheme.positive : AppTheme.destructive) Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, 16) .padding(.vertical, 14) } .buttonStyle(ScaleButtonStyle()) } .background(Color.white) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } private var photosLabel: String { switch vm.photosAuthStatus { case .authorized: return "Full access" case .limited: return "Limited" case .denied: return "Denied" default: return "Not set" } } private var hairline: some View { Rectangle() .fill(AppTheme.separator) .frame(height: 0.5) .padding(.leading, 36) } private func optionRow(icon: String, label: String, action: @escaping () -> Void) -> some View { Button(action: action) { HStack { Image(systemName: icon) .font(.system(size: 14, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) Text(label) .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, 16) .padding(.vertical, 14) } .buttonStyle(ScaleButtonStyle()) } // 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") } .buttonStyle(PrimaryButtonStyle( color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink )) case .running: Button { engine.pause() } label: { Text("Pause") } .buttonStyle(GhostButtonStyle()) case .paused: HStack(spacing: 10) { Button { engine.resume() } label: { Text("Resume") } .buttonStyle(PrimaryButtonStyle()) Button { engine.cancel() } label: { Text("Cancel") } .buttonStyle(GhostButtonStyle()) .frame(width: 90) } case .preparing: HStack(spacing: 8) { ProgressView() .scaleEffect(0.8) Text("Preparing…") .font(AppTheme.body()) .foregroundStyle(AppTheme.inkSecondary) } .frame(height: 50) } } .animation(.spring(response: 0.3, dampingFraction: 0.8), value: engine.job.status == .running) } 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 { bps >= 1_000_000 ? String(format: "%.1f MB/s", bps / 1_000_000) : String(format: "%.0f KB/s", bps / 1_000) } private func etaString(_ seconds: TimeInterval) -> String { Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s" } }