feat: add iPhone Storage and Cleanup pages to ring carousel
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 <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -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(
|
||||
.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,7 +420,6 @@ struct BackupView: View {
|
||||
}
|
||||
|
||||
private var statsStrip: some View {
|
||||
VStack(spacing: 7) {
|
||||
HStack(spacing: 0) {
|
||||
compactStat(
|
||||
"\(nasArchiveDisplayCount)",
|
||||
@@ -508,9 +448,6 @@ struct BackupView: View {
|
||||
.frame(height: 48)
|
||||
.background(AppTheme.surfaceSunken.opacity(0.85))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
|
||||
pageIndicator
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user