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 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 @State private var pullProgress: CGFloat = 0 @State private var rippleScale: CGFloat = 0.88 @State private var rippleOpacity: Double = 0 @State private var pulseDotOpacity: Double = 1.0 // 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 { Task { try? await Task.sleep(nanoseconds: 1_000_000_000) statusService.refresh(force: true) } } } } // MARK: — Logo mark (drawn in code — no asset dependency) // MARK: — Ring hero (used inside circleSection — no gesture here) private var ringHero: some View { ZStack { KisaniRingView( outerProgress: outerRingProgress, innerProgress: innerRingProgressWidget, innerColor: innerRingColor ) .accessibilityLabel("Backup progress") .accessibilityValue("\(Int(innerRingProgressWidget * 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) } .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) } private var ringContentID: String { String(describing: widgetState) } @ViewBuilder private var ringMainView: some View { switch widgetState { case .safe: VStack(spacing: 6) { ringCheckmark Text("\(statusService.snapshot.phoneTotal)") .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(AppTheme.positive) .contentTransition(.numericText()) .kerning(-2) Text("PHOTOS SAFE") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.positive.opacity(0.75)) .tracking(2) } case .checking: VStack(spacing: 10) { ProgressView() .scaleEffect(0.85) .tint(AppTheme.inkTertiary) Text("CHECKING") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) } case .running: VStack(spacing: 6) { Text("\(notBackedUpDisplayCount)") .font(.system(size: 46, weight: .ultraLight)) .foregroundStyle(AppTheme.ink) .contentTransition(.numericText()) .kerning(-2) Text("BACKING UP") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) } default: // offline, pending, paused 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" : "NEED BACKUP") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(2).opacity(0.75) } } } 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 innerRingProgressWidget: CGFloat { switch widgetState { case .safe: return 1.0 case .running: return CGFloat(engine.job.progress) default: return CGFloat(alreadySafeDisplayCount) / CGFloat(max(1, statusService.snapshot.phoneTotal)) } } // 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", color: AppTheme.interactive ) thinDivider compactStat( "\(statusService.snapshot.phoneTotal)", label: "IPHONE", color: AppTheme.inkSecondary ) thinDivider compactStat( "\(notBackedUpDisplayCount)", label: "PENDING", color: Color(red: 1.0, green: 0.62, blue: 0.0) ) thinDivider compactStat( "\(alreadySafeDisplayCount)", label: "SAFE", color: AppTheme.positive ) } .frame(height: 48) .background(AppTheme.surfaceSunken.opacity(0.85)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } 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)) } 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: — Widget state private enum WidgetState: Equatable { case offline, checking, pending, running, safe, paused } private var widgetState: WidgetState { if lanMonitor.nasReachable == false { return .offline } switch resolvedStatus { case .paused: return .paused case .running, .preparing: return .running case .completed: return .safe default: break } if statusService.isRefreshing || coordinator.phase == .checking { return .checking } if statusService.snapshot.needBackup == 0, statusService.snapshot.phoneTotal > 0 { return .safe } return .pending } private var innerRingColor: Color { switch widgetState { case .offline: return AppTheme.destructive case .checking: return Color(red: 0.58, green: 0.64, blue: 0.73) case .pending: return Color(red: 1.0, green: 0.62, blue: 0.0) case .running: return Color(red: 1.0, green: 0.62, blue: 0.0) case .safe: return AppTheme.positive case .paused: return Color(red: 0.82, green: 0.82, blue: 0.82) } } private var statusLabelText: String { switch widgetState { case .offline: return "NAS unreachable" case .checking: return "Checking…" case .pending: return "Ready to back up" case .running: return "Backing up" case .safe: return "All safe" case .paused: return "Backup paused" } } private var statusSubText: String { switch widgetState { case .offline: if let date = statusService.snapshot.lastCheckedAt { let s = Int(-date.timeIntervalSinceNow) return s < 60 ? "last seen \(s) sec ago" : "last seen \(s / 60) min ago" } return "unable to connect" case .checking: return "reconciling with NAS" case .pending: return "pull circle to start" case .running: if let eta = engine.job.eta { return "\(etaString(eta)) remaining" } return "uploading…" case .safe: return "synced with NAS" case .paused: return "pull circle to resume" } } private var dotColor: Color { switch widgetState { case .offline: return AppTheme.destructive case .checking: return AppTheme.inkTertiary case .pending: return Color(red: 1.0, green: 0.62, blue: 0.0) case .running: return Color(red: 1.0, green: 0.62, blue: 0.0) case .safe: return AppTheme.positive case .paused: return Color(red: 1.0, green: 0.62, blue: 0.0) } } private var dotShouldPulse: Bool { widgetState == .offline || widgetState == .pending || widgetState == .paused } private var showArrows: Bool { widgetState == .offline || widgetState == .pending || widgetState == .paused } private var arrowsColor: Color { widgetState == .offline ? AppTheme.destructive : Color(red: 1.0, green: 0.62, blue: 0.0) } // MARK: — Circle section private var circleSection: some View { VStack(spacing: 0) { pullZone ZStack { // Ripple expands outward on backup trigger Circle() .stroke(AppTheme.positive, lineWidth: 1.5) .frame(width: 258, height: 258) .scaleEffect(rippleScale) .opacity(rippleOpacity) .allowsHitTesting(false) ringHero } .offset(y: min(pullProgress * 0.26, 14)) .animation(.spring(response: 0.38, dampingFraction: 0.8), value: pullProgress) .gesture(pullGesture) .padding(.bottom, 4) cascadingArrows .frame(height: 16) .padding(.bottom, 8) circleStatusRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 20) } } private var pullZone: some View { let pct = min(pullProgress / 58, 1.0) let ready = pullProgress >= 58 return VStack(spacing: 3) { Text("↓") .font(.system(size: 13)) .foregroundStyle(ready ? AppTheme.positive : AppTheme.inkTertiary) .rotationEffect(.degrees(ready ? 180 : pct * 160)) .opacity(min(pct * 2.5, 1)) Text(ready ? "RELEASE TO BACK UP" : "PULL TO BACK UP") .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(ready ? AppTheme.positive : AppTheme.inkTertiary) .tracking(2) .opacity(min(pct * 3, 1)) } .padding(.bottom, 6) .frame(maxWidth: .infinity, maxHeight: max(0, pullProgress * 0.52), alignment: .bottom) .clipped() } private var cascadingArrows: some View { TimelineView(.animation(minimumInterval: 1.0 / 30, paused: !showArrows)) { tl in let t = tl.date.timeIntervalSince1970 HStack(spacing: 3) { ForEach(0..<3, id: \.self) { i in let phase = ((t.truncatingRemainder(dividingBy: 1.6) - Double(i) * 0.22) + 1.6) .truncatingRemainder(dividingBy: 1.6) / 1.6 Text("↓") .font(.system(size: 10)) .foregroundStyle(arrowsColor) .opacity(arrowFlowOpacity(phase)) .offset(y: arrowFlowOffset(phase)) } } } .opacity(showArrows ? 1 : 0) .animation(.easeInOut(duration: 0.4), value: showArrows) } private var circleStatusRow: some View { VStack(spacing: 4) { HStack(spacing: 6) { if widgetState == .checking || widgetState == .running { ProgressView() .scaleEffect(0.55) .tint(AppTheme.inkTertiary) .frame(width: 10, height: 10) } else { Circle() .fill(dotColor) .frame(width: 6, height: 6) .opacity(pulseDotOpacity) .onAppear { if dotShouldPulse { withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { pulseDotOpacity = 0.3 } } } .onChange(of: dotShouldPulse) { pulse in if pulse { withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { pulseDotOpacity = 0.3 } } else { withAnimation { pulseDotOpacity = 1.0 } } } } Text(statusLabelText) .font(.system(size: 13, weight: .regular)) .foregroundStyle(widgetState == .offline ? AppTheme.destructive : AppTheme.ink) } Text(statusSubText) .font(.system(size: 9, weight: .regular, design: .monospaced)) .foregroundStyle(AppTheme.inkTertiary) .tracking(0.08) } .multilineTextAlignment(.center) .id(widgetState) .transition(.opacity.combined(with: .offset(y: 8))) .animation(.easeInOut(duration: 0.35), value: widgetState) } // MARK: — Page switch gesture (used on pages 1 & 2 hero areas) private var pageSwitchGesture: some Gesture { DragGesture(minimumDistance: 30) .onEnded { value in let dx = value.translation.width guard abs(dx) > abs(value.translation.height) else { return } withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { if dx < 0 { ringPage = (ringPage + 1) % ringPageCount } else { ringPage = (ringPage - 1 + ringPageCount) % ringPageCount } } } } // MARK: — Pull gesture private var pullGesture: some Gesture { DragGesture(minimumDistance: 8) .onChanged { value in guard value.translation.height > 0 else { return } let dy = value.translation.height if pullProgress < 58 && dy >= 58 { UIImpactFeedbackGenerator(style: .light).impactOccurred() } pullProgress = dy } .onEnded { value in let fired = value.translation.height >= 58 withAnimation(.spring(response: 0.38, dampingFraction: 0.85)) { pullProgress = 0 } if fired { UIImpactFeedbackGenerator(style: .medium).impactOccurred() triggerBackupViaPull() } } } private func triggerBackupViaPull() { rippleScale = 0.88; rippleOpacity = 0.6 withAnimation(.easeOut(duration: 0.65)) { rippleScale = 1.35; rippleOpacity = 0 } if engine.job.status == .paused { engine.resume() } else { startBackup() } } private func arrowFlowOpacity(_ phase: Double) -> Double { if phase < 0.4 { return phase / 0.4 * 0.7 } if phase < 0.8 { return (0.8 - phase) / 0.4 * 0.7 } return 0 } private func arrowFlowOffset(_ phase: Double) -> CGFloat { if phase < 0.4 { return CGFloat(-3 + phase / 0.4 * 3) } if phase < 0.8 { return CGFloat((phase - 0.4) / 0.4 * 3) } return 3 } // 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 { circleSection statsStrip .padding(.horizontal, AppTheme.hPad) .transition(.opacity.combined(with: .scale(scale: 0.97))) Spacer(minLength: 16).frame(maxHeight: 32) nasStatusRow .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 12) destinationRow .padding(.horizontal, AppTheme.hPad) Spacer(minLength: 8).frame(maxHeight: 20) } @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 let innerColor: Color private let track = Color(red: 0.76, green: 0.76, blue: 0.76) private let nasArc = Color(red: 0.42, green: 0.52, blue: 0.68) var body: some View { ZStack { // Outer track Circle().stroke(track, lineWidth: 1).padding(10) // Outer NAS progress (blue-slate + glow) Circle().trim(from: 0, to: outerProgress) .stroke(nasArc, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) .shadow(color: nasArc.opacity(0.65), radius: 7, x: 0, y: 0) .rotationEffect(.degrees(-90)) .padding(10) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: outerProgress) // Middle static ring Circle().stroke(Color(red: 0.62, green: 0.62, blue: 0.62), lineWidth: 1).padding(24) // Inner track Circle().stroke(track, lineWidth: 1.5).padding(38) // Inner progress (color-variable) Circle().trim(from: 0, to: innerProgress) .stroke(innerColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(38) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress) .animation(.easeInOut(duration: 0.5), value: innerColor) } .frame(width: 240, height: 240) } } 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: 240, height: 240) } }