import SwiftUI import Photos struct BackupView: View { @EnvironmentObject var engine: BackupEngine @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor @EnvironmentObject var statusService: BackupStatusService @EnvironmentObject var coordinator: AutoBackupCoordinator @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 = 3 private let ringPageNames = ["BACKUP", "IPHONE STORAGE", "CLEANUP"] @State private var deviceTotalGB: Double = 0 @State private var deviceFreeGB: Double = 0 @State private var showCleanConfirm = false // Counts are the source of truth for final display state. // If job ended in .failed but reconciliation shows needBackup == 0, treat as .completed. private var resolvedStatus: BackupStatus { if case .failed = engine.job.status, !statusService.isRefreshing, statusService.snapshot.phoneTotal > 0, statusService.snapshot.needBackup == 0 { return .completed } return engine.job.status } var body: some View { ZStack(alignment: .topTrailing) { AppTheme.background.ignoresSafeArea() // ─── Scrollable content with pull-to-refresh ────────────── GeometryReader { geo in ScrollView(.vertical, showsIndicators: false) { 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) Spacer(minLength: 24).frame(maxHeight: 48) // ─── Per-page content ───────────────────────── if ringPage == 0 { page0Content } else if ringPage == 1 { page1Content } else { page2Content } Spacer(minLength: 0) // ─── Page navigation ────────────────────────── pageNav .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 24) } .frame(minHeight: geo.size.height) } .refreshable { await statusService.refreshAndWait(force: true) if statusService.snapshot.needBackup == 0 { engine.resolveSuccess() } // handleReconciliationComplete() fires via Combine and will // start auto-backup if needed — no explicit call required here. } } // ─── Menu button — floats above scroll content ───────────── 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) } .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) .animation(.spring(response: 0.4, dampingFraction: 0.85), value: ringPage) .confirmationDialog( "Clean \(String(format: "%.1f", junkGB)) GB from device?", isPresented: $showCleanConfirm, titleVisibility: .visible ) { Button("Delete Backed-Up Photos", role: .destructive) { /* TODO: trigger cleanup */ } Button("Cancel", role: .cancel) {} } message: { Text("This removes photos already safely backed up to your NAS.") } .task { coordinator.onActive() loadDeviceStorage() } .onChange(of: scenePhase) { phase in // Re-check every time the app returns to the foreground. if phase == .active { coordinator.onActive() } } .onChange(of: store.savedConnection?.remotePath) { _ in statusService.refresh(force: true) } .onChange(of: store.backupFilter) { _ in statusService.refresh(force: true) } .onChange(of: engine.job.status) { status in if status == .completed { 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 } try? await Task.sleep(nanoseconds: 500_000_000) statusService.refresh(force: true) } } // .failed auto-resolve is owned by AutoBackupCoordinator.handleReconciliationComplete. // It waits for refreshAfterBackup's writeManifest to finish before reconciling, then // calls engine.resolveSuccess() when needBackup == 0, which re-triggers .completed above. // Calling refreshAndWait here would race with writeManifest and read a stale NAS manifest. } } // MARK: — Logo mark (drawn in code — no asset dependency) // MARK: — Ring hero private var ringHero: some View { ZStack { KisaniRingView(outerProgress: outerRingProgress, innerProgress: innerRingProgress) .accessibilityLabel("Backup progress") .accessibilityValue("\(Int(innerRingProgress * 100)) percent") VStack(spacing: 4) { ringMainView .transition(.opacity.combined(with: .scale(scale: 0.95))) .id(ringContentID) } .animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID) } .gesture(pageSwitchGesture) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) } private var pageSwitchGesture: some Gesture { DragGesture(minimumDistance: 20) .onEnded { value in withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { if value.translation.width < 0 { ringPage = (ringPage + 1) % ringPageCount } else { ringPage = (ringPage - 1 + ringPageCount) % ringPageCount } } } } // ID that changes on both page swipe and status transition — drives the ring animation private var ringContentID: String { let enginePart: String switch resolvedStatus { case .idle, .cancelled: enginePart = "idle" case .preparing: enginePart = "prep" case .running: enginePart = "run" case .paused: enginePart = "pause" case .completed: enginePart = "done" case .failed: enginePart = "fail" } // Include coordinator phase so ring animates on checking/disconnected transitions let coordSuffix: String switch coordinator.phase { case .checking: coordSuffix = "_chk" case .disconnected: coordSuffix = "_disc" case .autoBackingUp: coordSuffix = "_auto" case .idle: coordSuffix = "" } return "\(enginePart)\(coordSuffix)" } // Page 0 content — changes based on backup state @ViewBuilder private var ringMainView: some View { switch resolvedStatus { case .idle, .cancelled: if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { VStack(spacing: 8) { ringCheckmark Text("ALL PHOTOS SAFE") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) } } else { VStack(spacing: 6) { Text("\(statusService.snapshot.needBackup)") .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .kerning(-2) Text(statusService.snapshot.phoneTotal == 0 ? "NO PHOTOS FOUND" : "NEED BACKUP") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) } } case .preparing: VStack(spacing: 10) { ProgressView() .scaleEffect(0.85) .tint(AppTheme.inkTertiary) Text("SCANNING LIBRARY") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) } case .running, .paused: VStack(spacing: 5) { Text(percentText) .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .kerning(-2) Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) OF \(engine.job.totalFiles)") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkSecondary) .tracking(1.5).opacity(0.75) if let eta = engine.job.eta { Text("~\(etaString(eta)) remaining") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(1.5).opacity(0.6) .padding(.top, 1) } } case .completed: VStack(spacing: 8) { ringCheckmark Text("ALL DONE") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) } case .failed: VStack(spacing: 8) { Image(systemName: "exclamationmark.circle") .font(.system(size: 26, weight: .light)) .foregroundStyle(AppTheme.destructive) Text("BACKUP FAILED") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.destructive.opacity(0.8)) .tracking(2).opacity(0.75) } } } // MARK: — Page indicator private var pageIndicator: some View { HStack(spacing: 5) { ForEach(0.. 0 { Text("\(coordinator.pendingAtDisconnect) QUEUED") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) .contentTransition(.numericText()) } } } private var ringCheckmark: some View { ZStack { Circle() .stroke(AppTheme.positive, lineWidth: 1) .frame(width: 22, height: 22) Image(systemName: "checkmark") .font(.system(size: 8, weight: .medium)) .foregroundStyle(AppTheme.positive) } } private var outerRingProgress: CGFloat { let nas = CGFloat(nasArchiveDisplayCount) let phone = CGFloat(max(1, statusService.snapshot.phoneTotal)) return min(nas / (nas + phone), 1) } private var innerRingProgress: CGFloat { CGFloat(alreadySafeDisplayCount) / CGFloat(max(1, statusService.snapshot.phoneTotal)) } private var ringColor: Color { if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) } if case .failed = resolvedStatus { return AppTheme.destructive } // Disconnected with pending items: dim orange pulse if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 { return Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.35) } if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { return AppTheme.positive } 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). // Live NAS Archive: grows by 1 with each confirmed upload this session. // Skipped files (already on NAS) are NOT added — if nasArchiveTotal came from // a directory scan they're already counted; if from manifest they weren't tracked // but they're present on disk regardless. private var nasArchiveDisplayCount: Int { let base = statusService.snapshot.nasArchiveTotal if engine.job.status.isActive { return base + engine.job.uploadedFiles } return base } 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 { // Uploaded = newly confirmed on NAS this session. // Skipped = fileExists returned true — already on NAS but wasn't in manifest. // Both are now confirmed safe; count them both optimistically. return min(base + engine.job.uploadedFiles + engine.job.skippedFiles, total) } return min(base, total) } 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 { HStack(spacing: 0) { compactStat( "\(nasArchiveDisplayCount)", 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)) } private var checkStatusRow: some View { VStack(alignment: .leading, spacing: 3) { 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) HStack(spacing: 4) { Image(systemName: syncStatusIcon) .font(.system(size: 9, weight: .medium)) .foregroundStyle(syncStatusColor) Text(syncStatusLabel) .font(.system(size: 9, weight: .medium)) .foregroundStyle(syncStatusColor) } .animation(.easeInOut(duration: 0.2), value: syncStatusLabel) } } private var syncStatusIcon: String { if engine.job.status == .preparing { return "magnifyingglass" } if engine.job.status.isActive { return "arrow.triangle.2.circlepath" } if engine.job.status == .paused { return "pause.fill" } if statusService.isRefreshing || coordinator.phase == .checking { return "magnifyingglass" } if statusService.snapshot.connectionState == .offline { return "wifi.slash" } return "stop.fill" } private var syncStatusColor: Color { if engine.job.status.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) } if engine.job.status == .paused { return Color(red: 1.0, green: 0.58, blue: 0.0) } if statusService.snapshot.connectionState == .offline { return AppTheme.destructive } return AppTheme.inkQuaternary } private var syncStatusLabel: String { if engine.job.status == .preparing { return "Checking" } if engine.job.status.isActive { return "Running" } if engine.job.status == .paused { return "Paused" } if statusService.isRefreshing || coordinator.phase == .checking { return "Checking" } if statusService.snapshot.connectionState == .offline { return "Offline" } return "Stopped" } 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(statusService.refreshError ?? "Unreachable") .font(.system(size: 9, weight: .regular)) .foregroundStyle(AppTheme.destructive.opacity(0.7)) .lineLimit(2) .multilineTextAlignment(.trailing) } 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 resolvedStatus { case .idle, .completed, .failed, .cancelled: Button(action: startBackup) { Text(resolvedStatus == .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" } // MARK: — Device storage private var deviceUsedGB: Double { max(0, deviceTotalGB - deviceFreeGB) } private var junkGB: Double { Double(alreadySafeDisplayCount) * 3.5 / 1000 } private var usedFraction: CGFloat { CGFloat(deviceUsedGB / max(1, deviceTotalGB)) } private var safeFraction: CGFloat { CGFloat(min(junkGB, deviceTotalGB) / max(1, deviceTotalGB)) } private var backedUpPhotosGB: Double { Double(alreadySafeDisplayCount) * 3.5 / 1000 } private var totalFreeableGB: Double { backedUpPhotosGB } private func loadDeviceStorage() { let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) let total = (attrs?[.systemSize] as? Int64) ?? 0 let free = (attrs?[.systemFreeSize] as? Int64) ?? 0 deviceTotalGB = Double(total) / 1_000_000_000 deviceFreeGB = Double(free) / 1_000_000_000 } // MARK: — Page content @ViewBuilder private var page0Content: some View { ringHero.padding(.bottom, 12) checkStatusRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 20) statsStrip .padding(.horizontal, AppTheme.hPad) .transition(.opacity.combined(with: .scale(scale: 0.97))) Spacer(minLength: 20).frame(maxHeight: 48) nasStatusRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) destinationRow .padding(.horizontal, AppTheme.hPad) Spacer(minLength: 20).frame(maxHeight: 32) if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited { photosAccessRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 8) .transition(.opacity.combined(with: .move(edge: .bottom))) } actionButton .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 8) } @ViewBuilder private var page1Content: some View { // Storage ring ZStack { StorageRingView(usedProgress: usedFraction, safeProgress: safeFraction) VStack(spacing: 6) { Image(systemName: "iphone") .font(.system(size: 14, weight: .ultraLight)) .foregroundStyle(AppTheme.inkTertiary) Text("\(Int(usedFraction * 100))%") .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0)) .kerning(-2) Text("STORAGE USED") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) } } .gesture(pageSwitchGesture) .padding(.bottom, 20) // 3-col stats HStack(spacing: 0) { storageStat(String(format: "%.0f GB", deviceUsedGB), label: "USED", color: Color(red: 1.0, green: 0.58, blue: 0.0)) thinDivider storageStat(String(format: "%.0f GB", deviceFreeGB), label: "FREE", color: AppTheme.inkSecondary) thinDivider storageStat(String(format: "%.1f GB", junkGB), label: "JUNK", color: AppTheme.positive) } .frame(height: 48) .background(AppTheme.surfaceSunken.opacity(0.85)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) // Legend HStack(spacing: 16) { storageLegend(color: Color(red: 1.0, green: 0.58, blue: 0.0), label: "used") storageLegend(color: AppTheme.positive, label: "safe to delete") storageLegend(color: AppTheme.destructive, label: "junk") } .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 20) Spacer(minLength: 16).frame(maxHeight: 40) Button(action: { showCleanConfirm = true }) { Text("HOLD TO CLEAN") .font(.system(size: 10, weight: .medium, design: .monospaced)) .tracking(2) } .buttonStyle(PrimaryButtonStyle()) .padding(.horizontal, AppTheme.hPad) .simultaneousGesture( LongPressGesture(minimumDuration: 0.8).onEnded { _ in showCleanConfirm = true } ) Text(String(format: "%.1f GB RECOVERABLE", junkGB)) .font(.system(size: 9, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(1.5) .padding(.top, 6) .padding(.bottom, 8) } @ViewBuilder private var page2Content: some View { // Freed amount HStack(alignment: .lastTextBaseline, spacing: 6) { Text(String(format: "%.1f", totalFreeableGB)) .font(.system(size: 64, weight: .ultraLight)) .foregroundStyle(AppTheme.positive) .kerning(-2) Text("GB FREED") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2) .offset(y: -10) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 20) .gesture(pageSwitchGesture) // Breakdown VStack(spacing: 0) { cleanupRow(label: "Backed-up photos", sizeGB: backedUpPhotosGB, color: AppTheme.positive) Divider().opacity(0.25).padding(.leading, AppTheme.hPad) cleanupRow(label: "Duplicates", sizeGB: 0, color: AppTheme.interactive) Divider().opacity(0.25).padding(.leading, AppTheme.hPad) cleanupRow(label: "Screenshots", sizeGB: 0, color: Color(red: 1.0, green: 0.58, blue: 0.0)) Divider().opacity(0.25).padding(.leading, AppTheme.hPad) cleanupRow(label: "Large videos", sizeGB: 0, color: AppTheme.inkQuaternary) } .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 24) Spacer(minLength: 16).frame(maxHeight: 40) Button { withAnimation { ringPage = 0 } } label: { Text("DONE") } .buttonStyle(GhostButtonStyle()) .frame(width: 120) .padding(.bottom, 8) } // MARK: — Page navigation private var pageNav: some View { HStack { Button { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { ringPage = (ringPage - 1 + ringPageCount) % ringPageCount } } label: { Image(systemName: "chevron.left") .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.inkTertiary) .frame(width: 30, height: 30) .background(AppTheme.surfaceSunken) .clipShape(Circle()) } Spacer() Text(ringPageNames[ringPage]) .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2) Spacer() Button { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { ringPage = (ringPage + 1) % ringPageCount } } label: { Image(systemName: "chevron.right") .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.inkTertiary) .frame(width: 30, height: 30) .background(AppTheme.surfaceSunken) .clipShape(Circle()) } } } // MARK: — Storage helpers private func storageStat(_ 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, design: .monospaced)) .foregroundStyle(AppTheme.inkQuaternary) .tracking(0.5) } .frame(maxWidth: .infinity) .padding(.vertical, 6) } private func storageLegend(color: Color, label: String) -> some View { HStack(spacing: 5) { RoundedRectangle(cornerRadius: 1) .fill(color) .frame(width: 14, height: 2) Text(label) .font(.system(size: 9, weight: .regular)) .foregroundStyle(AppTheme.inkTertiary) } } private func cleanupRow(label: String, sizeGB: Double, color: Color) -> some View { HStack(spacing: 12) { Text(label) .font(AppTheme.body(13)) .foregroundStyle(AppTheme.ink) Spacer() GeometryReader { geo in ZStack(alignment: .leading) { Rectangle().fill(AppTheme.surfaceSunken).frame(height: 2) Rectangle() .fill(color) .frame( width: max(4, geo.size.width * CGFloat( totalFreeableGB > 0 ? min(sizeGB / totalFreeableGB, 1) : 0 )), height: 2 ) } .frame(maxHeight: .infinity, alignment: .center) } .frame(height: 2) .frame(maxWidth: 140) Text(sizeGB > 0.01 ? String(format: "%.1f GB", sizeGB) : "—") .font(.system(size: 10, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .frame(width: 50, alignment: .trailing) } .padding(.vertical, 12) } } // MARK: — Ring private struct KisaniRingView: View { let outerProgress: CGFloat let innerProgress: CGFloat private let track = Color(red: 0.92, green: 0.92, blue: 0.92) var body: some View { ZStack { // Outer track Circle().stroke(track, lineWidth: 1).padding(10) // Outer progress (slate, animates) Circle().trim(from: 0, to: outerProgress) .stroke( Color(red: 0.58, green: 0.64, blue: 0.73), style: StrokeStyle(lineWidth: 1, lineCap: .round) ) .rotationEffect(.degrees(-90)) .padding(10) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: outerProgress) // Middle static ring (gray) Circle().stroke(Color(red: 0.78, green: 0.78, blue: 0.78), lineWidth: 1).padding(24) // Inner track Circle().stroke(track, lineWidth: 1.5).padding(38) // Inner progress (green, animates) Circle().trim(from: 0, to: innerProgress) .stroke( AppTheme.positive, style: StrokeStyle(lineWidth: 1.5, lineCap: .round) ) .rotationEffect(.degrees(-90)) .padding(38) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress) } .frame(width: 210, height: 210) } } private struct StorageRingView: View { let usedProgress: CGFloat let safeProgress: CGFloat private let track = Color(red: 0.92, green: 0.92, blue: 0.92) var body: some View { ZStack { // Outer track + used (orange) Circle().stroke(track, lineWidth: 1).padding(10) Circle().trim(from: 0, to: usedProgress) .stroke( Color(red: 1.0, green: 0.58, blue: 0.0), style: StrokeStyle(lineWidth: 1, lineCap: .round) ) .rotationEffect(.degrees(-90)) .padding(10) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: usedProgress) // Middle static ring (gray) Circle().stroke(Color(red: 0.78, green: 0.78, blue: 0.78), lineWidth: 1).padding(24) // Inner track + safe-to-delete (green) Circle().stroke(track, lineWidth: 1.5).padding(38) Circle().trim(from: 0, to: safeProgress) .stroke( AppTheme.positive, style: StrokeStyle(lineWidth: 1.5, lineCap: .round) ) .rotationEffect(.degrees(-90)) .padding(38) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: safeProgress) } .frame(width: 210, height: 210) } }