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 showDestinationPicker = false @State private var pickerPath: String = "/" @State private var ringPage = 0 @State private var nasFileCount: Int? = nil 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 } .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) } .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 { vm.loadPhotoCount(filter: store.backupFilter) await loadNASAndCompare() } } // MARK: — Logo mark (drawn in code — no asset dependency) private var kisaniLogoMark: some View { VStack(spacing: 0) { driveRow AppTheme.ink.frame(height: 1.5) driveRow AppTheme.ink.frame(height: 1.5) driveRow } .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 11, style: .continuous) .strokeBorder(AppTheme.ink, lineWidth: 2.5) ) .frame(width: 66, height: 66) .shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 2) } private var driveRow: some View { HStack(spacing: 0) { Spacer() Circle() .fill(AppTheme.ink) .frame(width: 7, height: 7) .padding(.trailing, 7) } .frame(maxWidth: .infinity) .frame(height: 18) .background(AppTheme.surfaceRaised) } // 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: // Not backed up VStack(spacing: 4) { Text("\(notBackedUpDisplayCount)") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() Text("not backed up") .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 vm.totalPhotoCount > 0 && vm.notBackedUpCount == 0 { // All phone photos already exist in NAS 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("\(vm.notBackedUpCount)") .font(.system(size: 44, weight: .semibold, design: .rounded)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .monospacedDigit() Text(vm.totalPhotoCount == 0 ? "No photos found" : "not backed up") .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.. 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) } private func loadNASAndCompare() async { guard let conn = store.savedConnection else { return } do { let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() try await s.connect(to: conn.host, port: conn.port, username: conn.username, password: conn.password) let items = try await s.listDirectory(at: conn.remotePath) nasFileCount = items.filter { !$0.isDirectory }.count vm.compareWithNAS(nasItems: items) s.disconnect() } catch { nasFileCount = nil // On failure: notBackedUpCount stays at totalPhotoCount (conservative — show all as pending) } } // 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 == .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" } }