import SwiftUI import Photos struct BackupView: View { @EnvironmentObject var engine: BackupEngine @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor @StateObject private var vm = BackupViewModel() @State private var showMenu = false @State private var ringPage = 0 private let ringPageCount = 4 var body: some View { ZStack { AppTheme.background.ignoresSafeArea() VStack(spacing: 0) { Spacer(minLength: 0) // ─── Ring hero ─────────────────────────────────────── ringHero .padding(.bottom, 24) // ─── Compact stats strip (always visible) ───────────── statsStrip .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) .transition(.opacity.combined(with: .scale(scale: 0.97))) Spacer(minLength: 0) // ─── NAS status ─────────────────────────────────────── nasStatusRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) // ─── Photos access nudge ────────────────────────────── if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited { photosAccessRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) .transition(.opacity.combined(with: .move(edge: .bottom))) } // ─── Action ─────────────────────────────────────────── actionButton .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 32) } } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { logoChip } ToolbarItem(placement: .navigationBarTrailing) { Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 16, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) } .accessibilityLabel("Activity menu") } } .sheet(isPresented: $showMenu) { AppMenuView() .environmentObject(store) .environmentObject(lanMonitor) } .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: — Logo chip private var logoChip: some View { HStack(spacing: 6) { ZStack { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color(red: 0.12, green: 0.64, blue: 0.38)) .frame(width: 22, height: 22) Image(systemName: "externaldrive.fill") .font(.system(size: 10, weight: .medium)) .foregroundStyle(.white.opacity(0.92)) } Text("kisani.") .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundStyle(AppTheme.inkSecondary) .kerning(0.5) } } // 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) { ringCenterContent .transition(.opacity.combined(with: .scale(scale: 0.95))) .id(ringPage) } .animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringPage) } .gesture( DragGesture(minimumDistance: 20) .onEnded { value in if value.translation.width < 0 { ringPage = (ringPage + 1) % ringPageCount } else { ringPage = (ringPage - 1 + ringPageCount) % ringPageCount } } ) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) } @ViewBuilder private var ringCenterContent: some View { switch ringPage { case 0: // Progress / status VStack(spacing: 4) { Text(percentText) .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() ringStatusLabel } case 1: // Pending VStack(spacing: 4) { Text("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() Text("pending") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } case 2: // Failed VStack(spacing: 4) { Text("\(engine.job.failedFiles)") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() Text("failed") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } default: // Completed VStack(spacing: 4) { Text("\(engine.job.uploadedFiles)") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.positive) .contentTransition(.numericText()) .monospacedDigit() Text("uploaded") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } } } @ViewBuilder private var ringStatusLabel: 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) } } // MARK: — Page indicator dots private var pageIndicator: some View { HStack(spacing: 5) { ForEach(0.. 0 ? AppTheme.destructive : AppTheme.inkQuaternary) thinDivider compactStat("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))", label: "left", color: AppTheme.inkSecondary) } .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) pageIndicator } } private func compactStat(_ value: String, label: String, color: Color) -> some View { HStack(spacing: 4) { Text(value) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(color) .monospacedDigit() Text(label) .font(.system(size: 10, weight: .regular)) .foregroundStyle(AppTheme.inkQuaternary) } .frame(maxWidth: .infinity) .padding(.vertical, 6) } private var thinDivider: some View { Rectangle() .fill(AppTheme.separator) .frame(width: 0.5) .padding(.vertical, 5) } // 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.displayName) .font(AppTheme.headline()) .foregroundStyle(AppTheme.ink) HStack(spacing: 4) { Text(conn.nasProtocol.rawValue) .foregroundStyle(AppTheme.inkTertiary) if let ssid = lanMonitor.currentSSID { Text("·") .foregroundStyle(AppTheme.inkQuaternary) Text(ssid) .foregroundStyle(AppTheme.inkTertiary) } } .font(AppTheme.micro(10)) } } else { Text("No NAS configured") .font(AppTheme.body()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() VStack(alignment: .trailing, spacing: 2) { HStack(spacing: 4) { 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) } if let reachable = lanMonitor.nasReachable { Text(reachable ? "NAS reachable" : "NAS offline") .font(.system(size: 9, weight: .regular)) .foregroundStyle(reachable ? AppTheme.positive.opacity(0.7) : AppTheme.destructive.opacity(0.7)) } } } .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" } }