Fix pull gesture, ring size, and manifest NAS count consistency
BackupView: - Move circle hero outside ScrollView so DragGesture no longer competes with the ScrollView's pull-to-refresh capture - Pull-to-refresh (native iOS gesture) now starts/resumes backup instead of only refreshing status - Ring diameter 240 → 270pt, ripple circle 258 → 288pt - Restructure: heroView (fixed) + ScrollView(belowContent) + pageNav - page1/page2 content split into heroView + belowContent dispatch BackupStatusService: - refreshAfterBackup now bumps nasArchiveTotal by newlyOnNAS count (fileSize > 0 entries only), preventing the NAS stat from flickering back to the pre-backup value while writeManifest runs Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -42,50 +42,36 @@ struct BackupView: View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
// ─── Scrollable content with pull-to-refresh ──────────────
|
||||
GeometryReader { geo in
|
||||
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: 16).frame(maxHeight: 40)
|
||||
|
||||
// ─── Hero — outside ScrollView so pull gesture fires ───
|
||||
heroView
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: ringPage)
|
||||
|
||||
// ─── Scrollable stats / detail ─────────────────────────
|
||||
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.
|
||||
belowContent
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.refreshable { await triggerRefreshAndBackup() }
|
||||
|
||||
// ─── Page navigation ───────────────────────────────────
|
||||
pageNav
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
|
||||
// ─── Menu button — floats above scroll content ─────────────
|
||||
@@ -492,6 +478,82 @@ struct BackupView: View {
|
||||
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
|
||||
}
|
||||
|
||||
private func triggerRefreshAndBackup() async {
|
||||
await statusService.refreshAndWait(force: true)
|
||||
if statusService.snapshot.needBackup == 0 {
|
||||
engine.resolveSuccess()
|
||||
return
|
||||
}
|
||||
if engine.job.status == .paused {
|
||||
engine.resume()
|
||||
} else if !engine.job.status.isActive {
|
||||
startBackup()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Hero / below dispatch
|
||||
|
||||
@ViewBuilder
|
||||
private var heroView: some View {
|
||||
switch ringPage {
|
||||
case 1:
|
||||
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, 8)
|
||||
case 2:
|
||||
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, 8)
|
||||
.gesture(pageSwitchGesture)
|
||||
default:
|
||||
circleSection
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var belowContent: some View {
|
||||
switch ringPage {
|
||||
case 1:
|
||||
page1BelowContent
|
||||
case 2:
|
||||
page2BelowContent
|
||||
default:
|
||||
statsStrip
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.top, 4)
|
||||
Spacer(minLength: 16).frame(maxHeight: 32)
|
||||
nasStatusRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
Spacer(minLength: 8).frame(maxHeight: 20)
|
||||
}
|
||||
}
|
||||
|
||||
private func etaString(_ seconds: TimeInterval) -> String {
|
||||
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
|
||||
}
|
||||
@@ -588,7 +650,7 @@ struct BackupView: View {
|
||||
// Ripple expands outward on backup trigger
|
||||
Circle()
|
||||
.stroke(AppTheme.positive, lineWidth: 1.5)
|
||||
.frame(width: 258, height: 258)
|
||||
.frame(width: 288, height: 288)
|
||||
.scaleEffect(rippleScale)
|
||||
.opacity(rippleOpacity)
|
||||
.allowsHitTesting(false)
|
||||
@@ -770,40 +832,10 @@ struct BackupView: View {
|
||||
|
||||
// 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)
|
||||
Spacer(minLength: 8).frame(maxHeight: 20)
|
||||
}
|
||||
// MARK: — Page below-content
|
||||
|
||||
@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)
|
||||
|
||||
private var page1BelowContent: some View {
|
||||
// 3-col stats
|
||||
HStack(spacing: 0) {
|
||||
storageStat(String(format: "%.0f GB", deviceUsedGB), label: "USED",
|
||||
@@ -852,24 +884,7 @@ struct BackupView: View {
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
private var page2BelowContent: some View {
|
||||
// Breakdown
|
||||
VStack(spacing: 0) {
|
||||
cleanupRow(label: "Backed-up photos", sizeGB: backedUpPhotosGB, color: AppTheme.positive)
|
||||
@@ -1027,7 +1042,7 @@ private struct KisaniRingView: View {
|
||||
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress)
|
||||
.animation(.easeInOut(duration: 0.5), value: innerColor)
|
||||
}
|
||||
.frame(width: 240, height: 240)
|
||||
.frame(width: 270, height: 270)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,6 +1079,6 @@ private struct StorageRingView: View {
|
||||
.padding(38)
|
||||
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: safeProgress)
|
||||
}
|
||||
.frame(width: 240, height: 240)
|
||||
.frame(width: 270, height: 270)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,12 +66,15 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {
|
||||
let uploaded = entries.count
|
||||
snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded)
|
||||
// nasArchiveTotal is NOT updated optimistically — writeManifest will update
|
||||
// NASManifestCache with the full merged count, and refresh(force: true) reads it.
|
||||
let safe = entries.count
|
||||
// Only count files actually uploaded (fileSize > 0) toward nasArchiveTotal.
|
||||
// Collision-skipped and fileExists-skipped entries have fileSize 0 and were
|
||||
// already counted in the last directory scan — adding them would double-count.
|
||||
let newlyOnNAS = entries.filter { $0.fileSize > 0 }.count
|
||||
snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + safe)
|
||||
snapshot.nasArchiveTotal += newlyOnNAS
|
||||
saveSnapshot()
|
||||
log.info("Post-backup optimistic update: +\(uploaded) safe")
|
||||
log.info("Post-backup optimistic update: +\(safe) safe, +\(newlyOnNAS) NAS")
|
||||
Task {
|
||||
await writeManifest(entries: entries, connection: connection)
|
||||
refresh(force: true)
|
||||
|
||||
Reference in New Issue
Block a user