import SwiftUI import Photos struct BackupView: View { @EnvironmentObject var engine: BackupEngine @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor @EnvironmentObject var statusService: BackupStatusService @StateObject private var vm = BackupViewModel() @Environment(\.scenePhase) private var scenePhase @State private var showMenu = false @State private var showDestinationPicker = false @State private var pickerPath: String = "/" @State private var ringPage = 0 @State private var ctaFlashGreen = false private let ringPageCount = 4 var body: some View { ZStack(alignment: .topTrailing) { AppTheme.background.ignoresSafeArea() // ─── Menu — aligned with kisani. wordmark ────────────────── Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 14, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .accessibilityLabel("Activity menu") .padding(.top, 4) .padding(.trailing, AppTheme.hPad - 4) VStack(spacing: 0) { // ─── Brand ─────────────────────────────────────────── VStack(spacing: 10) { Text("kisani.") .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundStyle(AppTheme.ink) .kerning(2) KisaniLogoMark(size: 66) } .frame(maxWidth: .infinity) .padding(.top, 16) // flex zone — brand ↔ ring (absorbs screen-size variance) Spacer(minLength: 24).frame(maxHeight: 48) // ─── Ring ───────────────────────────────────────────── ringHero .padding(.bottom, 44) // ─── Stats ──────────────────────────────────────────── statsStrip .padding(.horizontal, AppTheme.hPad) .transition(.opacity.combined(with: .scale(scale: 0.97))) // flex zone — stats ↔ cards Spacer(minLength: 20).frame(maxHeight: 48) // ─── Storage card ───────────────────────────────────── nasStatusRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) // ─── Source card ────────────────────────────────────── destinationRow .padding(.horizontal, AppTheme.hPad) // flex zone — cards ↔ CTA Spacer(minLength: 20).frame(maxHeight: 32) // ─── Photos access nudge ────────────────────────────── if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited { photosAccessRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 8) .transition(.opacity.combined(with: .move(edge: .bottom))) } // ─── CTA ────────────────────────────────────────────── actionButton .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 24) // bottom absorber — soaks up remaining height to anchor cards+CTA Spacer(minLength: 0) } } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showMenu) { AppMenuView() .environmentObject(store) .environmentObject(lanMonitor) .environmentObject(engine) } .sheet(isPresented: $showDestinationPicker, onDismiss: { guard pickerPath != "/", var conn = store.savedConnection else { return } conn.remotePath = pickerPath store.savedConnection = conn }) { if let conn = store.savedConnection { BrowseView(connection: conn, selectedPath: $pickerPath, isPickerMode: true, initialPath: pickerPath) } } .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 { statusService.refresh(force: true) } .onChange(of: scenePhase) { phase in if phase == .active { statusService.refresh(force: true) } } .onChange(of: lanMonitor.nasReachable) { reachable in if reachable == true { statusService.refresh(force: true) } } .onChange(of: store.savedConnection?.remotePath) { _ in statusService.refresh(force: true) } .onChange(of: engine.job.status) { status in if status == .completed { // Flash green then revert to black withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true } Task { try? await Task.sleep(nanoseconds: 2_500_000_000) withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false } // Full reconcile after flash to get accurate counts try? await Task.sleep(nanoseconds: 500_000_000) statusService.refresh(force: true) } } } } // MARK: — Logo mark (drawn in code — no asset dependency) // MARK: — Ring hero private var ringHero: some View { ZStack { ProgressRing( progress: engine.job.progress, lineWidth: 8, size: 206, color: ringColor, backgroundColor: AppTheme.inkQuaternary.opacity(0.6) ) .shadow( color: ringColor.opacity(engine.job.status.isActive ? 0.16 : 0), radius: 22, x: 0, y: 0 ) .animation(.easeInOut(duration: 0.5), value: engine.job.status.isActive) .accessibilityLabel("Backup progress") .accessibilityValue("\(Int(engine.job.progress * 100)) percent") VStack(spacing: 4) { ringCenterContent .transition(.opacity.combined(with: .scale(scale: 0.95))) .id(ringContentID) } .animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID) } .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) } // ID that changes on both page swipe and status transition — drives the ring animation private var ringContentID: String { switch engine.job.status { case .idle, .cancelled: return "\(ringPage)_idle" case .preparing: return "\(ringPage)_preparing" case .running: return "\(ringPage)_running" case .paused: return "\(ringPage)_paused" case .completed: return "\(ringPage)_done" case .failed: return "\(ringPage)_failed" } } @ViewBuilder private var ringCenterContent: some View { switch ringPage { case 0: ringMainView case 1: // Need backup VStack(spacing: 4) { Text("\(notBackedUpDisplayCount)") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() Text("need backup") .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: // Backed up VStack(spacing: 4) { Text("\(engine.job.uploadedFiles)") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.positive) .contentTransition(.numericText()) .monospacedDigit() Text("backed up") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } } } // Page 0 content — changes based on backup state @ViewBuilder private var ringMainView: some View { switch engine.job.status { case .idle, .cancelled: if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { VStack(spacing: 8) { Image(systemName: "checkmark") .font(.system(size: 30, weight: .medium)) .foregroundStyle(AppTheme.positive) Text("All photos safe") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } } else { VStack(spacing: 6) { Text("\(statusService.snapshot.needBackup)") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() Text(statusService.snapshot.phoneTotal == 0 ? "No photos found" : "need backup") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } } case .preparing: VStack(spacing: 10) { ProgressView() .scaleEffect(0.85) .tint(AppTheme.inkTertiary) Text("Scanning library…") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } case .running, .paused: VStack(spacing: 5) { Text(percentText) .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() Text("\(engine.job.uploadedFiles) of \(engine.job.totalFiles) backed up") .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) .padding(.top, 1) } } case .completed: VStack(spacing: 8) { Image(systemName: "checkmark") .font(.system(size: 30, weight: .medium)) .foregroundStyle(AppTheme.positive) Text("\(engine.job.uploadedFiles) photos backed up") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } case .failed: VStack(spacing: 8) { Image(systemName: "exclamationmark.circle") .font(.system(size: 30, weight: .light)) .foregroundStyle(AppTheme.destructive) Text("Backup failed") .font(.system(size: 13, weight: .medium)) .foregroundStyle(AppTheme.destructive.opacity(0.8)) } } } // MARK: — Page indicator private var pageIndicator: some View { HStack(spacing: 5) { ForEach(0.. 0 && statusService.snapshot.needBackup == 0 { return AppTheme.positive } // Default: neutral return AppTheme.inkQuaternary.opacity(0.55) } private var percentText: String { "\(Int(engine.job.progress * 100))%" } // MARK: — Compact stats strip // Invariant: alreadySafe + needBackup == phoneTotal always. // During active backup show optimistic count (only newly uploaded, not skipped — // skipped files are already counted inside snapshot.alreadySafe from the last reconcile). private var alreadySafeDisplayCount: Int { let total = statusService.snapshot.phoneTotal guard total > 0 else { return 0 } let base = statusService.snapshot.alreadySafe if engine.job.status.isActive { return min(base + engine.job.uploadedFiles, total) } return min(base, total) // enforce invariant at all times } private var notBackedUpDisplayCount: Int { let total = statusService.snapshot.phoneTotal guard total > 0 else { return 0 } if engine.job.status.isActive { return max(0, total - alreadySafeDisplayCount) } return statusService.snapshot.needBackup } private var statsStrip: some View { VStack(spacing: 7) { HStack(spacing: 0) { compactStat( "\(statusService.snapshot.nasArchiveTotal)", label: "NAS Archive", color: AppTheme.interactive ) thinDivider compactStat( "\(statusService.snapshot.phoneTotal)", label: "On iPhone", color: AppTheme.inkSecondary ) thinDivider compactStat( "\(notBackedUpDisplayCount)", label: "Need Backup", color: AppTheme.inkSecondary ) thinDivider compactStat( "\(alreadySafeDisplayCount)", label: "Already Safe", color: AppTheme.positive ) } .frame(height: 48) .background(AppTheme.surfaceSunken.opacity(0.85)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) pageIndicator // Subtle refresh status HStack(spacing: 5) { if statusService.isRefreshing { ProgressView() .scaleEffect(0.5) .tint(AppTheme.inkQuaternary) Text("Checking…") .font(.system(size: 10, weight: .regular)) .foregroundStyle(AppTheme.inkQuaternary) } else if let checked = statusService.snapshot.lastCheckedAt { Image(systemName: statusService.snapshot.connectionState == .offline ? "wifi.slash" : "checkmark.circle") .font(.system(size: 9, weight: .regular)) .foregroundStyle(statusService.snapshot.connectionState == .offline ? .orange : AppTheme.inkQuaternary) Text(checked, style: .relative) .font(.system(size: 10, weight: .regular)) .foregroundColor(AppTheme.inkQuaternary) + Text(" ago") .font(.system(size: 10, weight: .regular)) .foregroundColor(AppTheme.inkQuaternary) } Spacer() Button { statusService.refresh(force: true) } label: { Image(systemName: "arrow.clockwise") .font(.system(size: 11, weight: .regular)) .foregroundStyle(AppTheme.inkQuaternary) } } .frame(height: 16) } } private func compactStat(_ value: String, label: String, color: Color) -> some View { VStack(spacing: 2) { Text(value) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(color) .monospacedDigit() Text(label) .font(.system(size: 9, weight: .regular)) .foregroundStyle(AppTheme.inkQuaternary) .tracking(0.2) } .frame(maxWidth: .infinity) .padding(.vertical, 6) } private var thinDivider: some View { Rectangle() .fill(AppTheme.separator.opacity(0.5)) .frame(width: 0.5, height: 18) } // MARK: — Destination row private var destinationRow: some View { Button(action: { pickerPath = store.savedConnection?.remotePath ?? "/" showDestinationPicker = true }) { HStack(spacing: 10) { ZStack { Circle() .fill(AppTheme.surfaceSunken) .frame(width: 34, height: 34) Image(systemName: "folder.fill") .font(.system(size: 13, weight: .medium)) .foregroundStyle(AppTheme.inkSecondary) } let path = store.savedConnection?.remotePath ?? "/" let folderName = path == "/" ? "Not set" : (path as NSString).lastPathComponent VStack(alignment: .leading, spacing: 1) { Text(folderName) .font(AppTheme.headline()) .foregroundStyle(path == "/" ? AppTheme.inkTertiary : AppTheme.ink) Text("Backup folder") .font(AppTheme.micro(10)) .foregroundStyle(AppTheme.inkTertiary) } Spacer() Image(systemName: "chevron.right") .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, 14) .padding(.vertical, 12) .background(AppTheme.surfaceSunken) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) } .buttonStyle(ScaleButtonStyle()) .disabled(store.savedConnection == nil) } private var nasStatusDotColor: Color { switch engine.job.status { case .running, .preparing, .paused: return Color(red: 1.0, green: 0.58, blue: 0.0) default: switch lanMonitor.nasReachable { case true: return AppTheme.positive case false: return AppTheme.destructive case nil: return AppTheme.inkQuaternary } } } private var nasStatusLabel: String { switch engine.job.status { case .running: return "Backing up" case .preparing: return "Preparing" case .paused: return "Paused" default: return lanMonitor.nasReachable == false ? "Offline" : "Ready" } } // 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 { let friendlyName = conn.displayName == conn.host ? "Home Server" : conn.displayName VStack(alignment: .leading, spacing: 1) { Text(friendlyName) .font(AppTheme.headline()) .foregroundStyle(AppTheme.ink) Text("\(conn.nasProtocol.rawValue) · \(conn.host)") .font(AppTheme.micro(10)) .foregroundStyle(AppTheme.inkTertiary) } } else { Text("No storage connected") .font(AppTheme.body()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() VStack(alignment: .trailing, spacing: 2) { HStack(spacing: 4) { Circle() .fill(nasStatusDotColor) .frame(width: 6, height: 6) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusDotColor == AppTheme.positive) Text(nasStatusLabel) .font(AppTheme.micro()) .foregroundStyle(nasStatusDotColor) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel) } if lanMonitor.nasReachable == false { Text("Unreachable") .font(.system(size: 9, weight: .regular)) .foregroundStyle(AppTheme.destructive.opacity(0.7)) } else if lanMonitor.nasReachable == true && !engine.job.status.isActive { Text("Connected") .font(.system(size: 9, weight: .regular)) .foregroundStyle(AppTheme.positive.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 == .idle ? "Start backup" : "Back up again") } .buttonStyle(PrimaryButtonStyle( color: ctaFlashGreen ? 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" } }