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 { AppTheme.background.ignoresSafeArea() VStack(spacing: 0) { Spacer(minLength: 0) // ─── Ring hero ─────────────────────────────────────── ringHero .padding(.bottom, 28) // ─── Completion summary (only on .completed) ───────── if case .completed = engine.job.status { completionStrip .padding(.bottom, 28) .transition(.opacity.combined(with: .scale(scale: 0.97))) } Spacer(minLength: 0) // ─── NAS status ─────────────────────────────────────── nasStatusRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 16) // ─── Photos access nudge ────────────────────────────── if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited { photosAccessRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 16) .transition(.opacity.combined(with: .move(edge: .bottom))) } // ─── Action ─────────────────────────────────────────── actionButton .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 32) } } .animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed) .animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized) .task { vm.loadPhotoCount(filter: store.backupFilter) } } // MARK: — Ring hero private var ringHero: some View { ZStack { ProgressRing( progress: engine.job.progress, lineWidth: 7, size: 220, color: ringColor, backgroundColor: AppTheme.inkQuaternary.opacity(0.4) ) .accessibilityLabel("Backup progress") .accessibilityValue("\(Int(engine.job.progress * 100)) percent") VStack(spacing: 4) { Text(percentText) .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() ringSubtitle .transition(.opacity) } } .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) } @ViewBuilder private var ringSubtitle: some View { switch engine.job.status { case .running: VStack(spacing: 2) { 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) } Text(engine.job.currentFileName) .font(.system(size: 11, weight: .regular)) .foregroundStyle(AppTheme.inkTertiary) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: 160) } case .preparing: Text("Preparing…") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) case .completed: Text("Done") .font(.system(size: 13, weight: .medium)) .foregroundStyle(AppTheme.positive) case .failed: Text("Failed") .font(.system(size: 13, weight: .medium)) .foregroundStyle(AppTheme.destructive) case .cancelled: Text("Cancelled") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) default: Text(vm.totalPhotoCount > 0 ? "\(vm.totalPhotoCount) photos ready" : "Ready") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } } 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))%" } // MARK: — Completion strip private var completionStrip: some View { HStack(spacing: 0) { statCell("\(engine.job.uploadedFiles)", label: "uploaded") thinDivider statCell("\(engine.job.skippedFiles)", label: "skipped") thinDivider statCell( "\(engine.job.failedFiles)", label: "errors", valueColor: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink ) } .padding(.horizontal, AppTheme.hPad) .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .padding(.horizontal, AppTheme.hPad) } private func statCell(_ value: String, label: String, valueColor: Color = AppTheme.ink) -> some View { VStack(spacing: 3) { Text(value) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(valueColor) .monospacedDigit() Text(label) .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } .frame(maxWidth: .infinity) .padding(.vertical, 14) } private var thinDivider: some View { Rectangle() .fill(AppTheme.separator) .frame(width: 0.5) .padding(.vertical, 10) } // MARK: — NAS status row private var nasStatusRow: some View { HStack(spacing: 10) { ZStack { Circle() .fill(AppTheme.surfaceSunken) .frame(width: 34, height: 34) 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.nasProtocol.rawValue) .font(AppTheme.micro(10)) .foregroundStyle(AppTheme.inkTertiary) } } else { Text("No NAS configured") .font(AppTheme.body()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() HStack(spacing: 5) { Circle() .fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary) .frame(width: 6, height: 6) Text(engine.job.status.isActive ? "Active" : "Ready") .font(AppTheme.micro()) .foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary) } } .padding(.horizontal, 14) .padding(.vertical, 12) .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) } // MARK: — Photos access nudge private var photosAccessRow: some View { Button(action: { Task { await vm.requestPhotosAccess() } }) { HStack(spacing: 10) { Image(systemName: "photo.on.rectangle") .font(.system(size: 13, weight: .medium)) .foregroundStyle(AppTheme.destructive) .frame(width: 20) Text("Grant photo library access to enable backup") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkSecondary) Spacer() Image(systemName: "chevron.right") .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, 14) .padding(.vertical, 11) .background(AppTheme.destructive.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } .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 )) .accessibilityHint("Starts backing up your photos to the NAS") case .running: Button { engine.pause() } label: { Text("Pause") } .buttonStyle(GhostButtonStyle()) .accessibilityLabel("Pause backup") case .paused: HStack(spacing: 10) { Button { engine.resume() } label: { Text("Resume") } .buttonStyle(PrimaryButtonStyle()) .accessibilityLabel("Resume backup") Button { engine.cancel() } label: { Text("Cancel") } .buttonStyle(GhostButtonStyle()) .frame(width: 90) .accessibilityLabel("Cancel backup") } case .preparing: HStack(spacing: 8) { ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary) Text("Preparing…") .font(AppTheme.body()) .foregroundStyle(AppTheme.inkSecondary) } .frame(height: 50) } } .animation(.spring(response: 0.3, dampingFraction: 0.82), 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 etaString(_ seconds: TimeInterval) -> String { Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s" } }