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:
Robin Kutesa
2026-05-20 20:31:43 +03:00
parent ca333918a6
commit 13806a2089
2 changed files with 118 additions and 100 deletions

View File

@@ -42,50 +42,36 @@ struct BackupView: View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
AppTheme.background.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
// Scrollable content with pull-to-refresh VStack(spacing: 0) {
GeometryReader { geo in
// 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) { ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) { belowContent
.padding(.bottom, 16)
// 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.
} }
.refreshable { await triggerRefreshAndBackup() }
// Page navigation
pageNav
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
} }
// Menu button floats above scroll content // Menu button floats above scroll content
@@ -492,6 +478,82 @@ struct BackupView: View {
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) } 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 { private func etaString(_ seconds: TimeInterval) -> String {
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s" Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
} }
@@ -588,7 +650,7 @@ struct BackupView: View {
// Ripple expands outward on backup trigger // Ripple expands outward on backup trigger
Circle() Circle()
.stroke(AppTheme.positive, lineWidth: 1.5) .stroke(AppTheme.positive, lineWidth: 1.5)
.frame(width: 258, height: 258) .frame(width: 288, height: 288)
.scaleEffect(rippleScale) .scaleEffect(rippleScale)
.opacity(rippleOpacity) .opacity(rippleOpacity)
.allowsHitTesting(false) .allowsHitTesting(false)
@@ -770,40 +832,10 @@ struct BackupView: View {
// MARK: Page content // MARK: Page content
@ViewBuilder // MARK: Page below-content
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)
}
@ViewBuilder @ViewBuilder
private var page1Content: some View { private var page1BelowContent: 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 // 3-col stats
HStack(spacing: 0) { HStack(spacing: 0) {
storageStat(String(format: "%.0f GB", deviceUsedGB), label: "USED", storageStat(String(format: "%.0f GB", deviceUsedGB), label: "USED",
@@ -852,24 +884,7 @@ struct BackupView: View {
} }
@ViewBuilder @ViewBuilder
private var page2Content: some View { private var page2BelowContent: 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 // Breakdown
VStack(spacing: 0) { VStack(spacing: 0) {
cleanupRow(label: "Backed-up photos", sizeGB: backedUpPhotosGB, color: AppTheme.positive) 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(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress)
.animation(.easeInOut(duration: 0.5), value: innerColor) .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) .padding(38)
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: safeProgress) .animation(.spring(response: 0.7, dampingFraction: 0.8), value: safeProgress)
} }
.frame(width: 240, height: 240) .frame(width: 270, height: 270)
} }
} }

View File

@@ -66,12 +66,15 @@ final class BackupStatusService: NSObject, ObservableObject {
} }
func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {
let uploaded = entries.count let safe = entries.count
snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded) // Only count files actually uploaded (fileSize > 0) toward nasArchiveTotal.
// nasArchiveTotal is NOT updated optimistically writeManifest will update // Collision-skipped and fileExists-skipped entries have fileSize 0 and were
// NASManifestCache with the full merged count, and refresh(force: true) reads it. // 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() saveSnapshot()
log.info("Post-backup optimistic update: +\(uploaded) safe") log.info("Post-backup optimistic update: +\(safe) safe, +\(newlyOnNAS) NAS")
Task { Task {
await writeManifest(entries: entries, connection: connection) await writeManifest(entries: entries, connection: connection)
refresh(force: true) refresh(force: true)