From 5f6d1a676bfacbe6d1eebf75fadb47910da51c21 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Tue, 19 May 2026 18:24:55 +0300 Subject: [PATCH] feat: add iPhone Storage and Cleanup pages to ring carousel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 4-page in-ring stat cycling with 3 full-page views: - Page 0 (BACKUP): existing ring + status + NAS cards + CTA - Page 1 (IPHONE STORAGE): StorageRingView with orange used/green safe-to-delete arcs, 3-col GB stats, legend, "HOLD TO CLEAN" button - Page 2 (CLEANUP): freed GB hero, category breakdown bars (backed-up photos, duplicates, screenshots, large videos), "DONE" button Adds ← PAGE NAME → navigation bar replacing the dot indicator. SwipeGesture now shared via pageSwitchGesture applied per page hero. Co-Authored-By: Kutesir Co-Authored-By: Sentry --- Features/Backup/BackupView.swift | 489 +++++++++++++++++++++++-------- 1 file changed, 359 insertions(+), 130 deletions(-) diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 6fd83a9..cdefd27 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -16,7 +16,12 @@ struct BackupView: View { @State private var ringPage = 0 @State private var ctaFlashGreen = false - private let ringPageCount = 4 + 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. @@ -52,47 +57,21 @@ struct BackupView: View { Spacer(minLength: 24).frame(maxHeight: 48) - // ─── Ring ───────────────────────────────────── - ringHero - .padding(.bottom, 12) - - // ─── Check status ───────────────────────────── - checkStatusRow - .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 20) - - // ─── Stats ──────────────────────────────────── - statsStrip - .padding(.horizontal, AppTheme.hPad) - .transition(.opacity.combined(with: .scale(scale: 0.97))) - - Spacer(minLength: 20).frame(maxHeight: 48) - - // ─── Storage card ───────────────────────────── - nasStatusRow - .padding(.horizontal, AppTheme.hPad) - .padding(.bottom, 12) - - // ─── Destination card ────────────────────────── - destinationRow - .padding(.horizontal, AppTheme.hPad) - - 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))) + // ─── Per-page content ───────────────────────── + if ringPage == 0 { + page0Content + } else if ringPage == 1 { + page1Content + } else { + page2Content } - // ─── CTA ────────────────────────────────────── - actionButton + Spacer(minLength: 0) + + // ─── Page navigation ────────────────────────── + pageNav .padding(.horizontal, AppTheme.hPad) .padding(.bottom, 24) - - Spacer(minLength: 0) } .frame(minHeight: geo.size.height) } @@ -138,9 +117,20 @@ struct BackupView: View { } .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 { - // Kick off reconciliation and auto-backup on first appear. coordinator.onActive() + loadDeviceStorage() } .onChange(of: scenePhase) { phase in // Re-check every time the app returns to the foreground. @@ -181,23 +171,27 @@ struct BackupView: View { .accessibilityValue("\(Int(innerRingProgress * 100)) percent") VStack(spacing: 4) { - ringCenterContent + ringMainView .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 + .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 } } - ) - .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 @@ -219,60 +213,7 @@ struct BackupView: View { case .autoBackingUp: coordSuffix = "_auto" case .idle: coordSuffix = "" } - return "\(ringPage)_\(enginePart)\(coordSuffix)" - } - - @ViewBuilder - private var ringCenterContent: some View { - switch ringPage { - case 0: - if engine.job.status.isActive { - ringMainView - } else if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 { - disconnectedRingView - } else { - ringMainView - } - - case 1: // Need backup - VStack(spacing: 4) { - Text("\(notBackedUpDisplayCount)") - .font(.system(size: 46, weight: .ultraLight)) - .foregroundStyle(AppTheme.ink) - .contentTransition(.numericText()) - .kerning(-2) - Text("NEED BACKUP") - .font(.system(size: 8, weight: .regular, design: .monospaced)) - .foregroundStyle(AppTheme.inkTertiary) - .tracking(2).opacity(0.75) - } - - case 2: // Failed - VStack(spacing: 4) { - Text("\(engine.job.failedFiles)") - .font(.system(size: 46, weight: .ultraLight)) - .foregroundStyle(engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink) - .contentTransition(.numericText()) - .kerning(-2) - Text("FAILED") - .font(.system(size: 8, weight: .regular, design: .monospaced)) - .foregroundStyle(AppTheme.inkTertiary) - .tracking(2).opacity(0.75) - } - - default: // Backed up - VStack(spacing: 4) { - Text("\(engine.job.uploadedFiles)") - .font(.system(size: 46, weight: .ultraLight)) - .foregroundStyle(AppTheme.positive) - .contentTransition(.numericText()) - .kerning(-2) - Text("BACKED UP") - .font(.system(size: 8, weight: .regular, design: .monospaced)) - .foregroundStyle(AppTheme.inkTertiary) - .tracking(2).opacity(0.75) - } - } + return "\(enginePart)\(coordSuffix)" } // Page 0 content — changes based on backup state @@ -479,38 +420,34 @@ struct BackupView: View { } private var statsStrip: some View { - VStack(spacing: 7) { - 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)) - - pageIndicator + 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 { @@ -809,6 +746,261 @@ struct BackupView: View { 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 @@ -851,3 +1043,40 @@ private struct KisaniRingView: View { .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) + } +}