Fix backup progress state machine: pending queue, resolvedStatus, failure resolution
- BackupEngine: pre-filter assets against ManifestIndex before job.start() so totalFiles reflects only pending items (not full gallery). Short-circuit with allAlreadySafe() when queue is empty. - BackupJob: add resolveSuccess() (upgrades stale .failed → .completed after reconciliation proves needBackup==0) and allAlreadySafe() for empty-queue case. - BackupStatusService: expose refreshAndWait(force:) async for pull-to-refresh and post-failure reconciliation. - BackupView: wire resolvedStatus throughout ringContentID, ringMainView, ringColor, and actionButton; fix progress label to show pending-queue denominator; handle .failed in onChange by reconciling and auto-resolving to .completed when counts confirm all photos are safe. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -17,11 +17,89 @@ struct BackupView: View {
|
||||
|
||||
private let ringPageCount = 4
|
||||
|
||||
// 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()
|
||||
|
||||
// ─── Menu — aligned with kisani. wordmark ──────────────────
|
||||
// ─── 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)
|
||||
|
||||
// ─── Ring ─────────────────────────────────────
|
||||
ringHero
|
||||
.padding(.bottom, 44)
|
||||
|
||||
// ─── 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)))
|
||||
}
|
||||
|
||||
// ─── CTA ──────────────────────────────────────
|
||||
actionButton
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(minHeight: geo.size.height)
|
||||
}
|
||||
.refreshable {
|
||||
await statusService.refreshAndWait(force: true)
|
||||
// After reconciliation, clear stale failure if counts say all safe
|
||||
if statusService.snapshot.needBackup == 0 {
|
||||
engine.resolveSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Menu button — floats above scroll content ─────────────
|
||||
Button { showMenu = true } label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
@@ -32,63 +110,6 @@ struct BackupView: View {
|
||||
.accessibilityLabel("Activity menu")
|
||||
.padding(.top, 4)
|
||||
.padding(.trailing, AppTheme.hPad - 4)
|
||||
|
||||
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)
|
||||
|
||||
// flex zone — brand ↔ ring (absorbs screen-size variance)
|
||||
Spacer(minLength: 24).frame(maxHeight: 48)
|
||||
|
||||
// ─── Ring ─────────────────────────────────────────────
|
||||
ringHero
|
||||
.padding(.bottom, 44)
|
||||
|
||||
// ─── Stats ────────────────────────────────────────────
|
||||
statsStrip
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.97)))
|
||||
|
||||
// flex zone — stats ↔ cards
|
||||
Spacer(minLength: 20).frame(maxHeight: 48)
|
||||
|
||||
// ─── Storage card ─────────────────────────────────────
|
||||
nasStatusRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// ─── Source card ──────────────────────────────────────
|
||||
destinationRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
|
||||
// flex zone — cards ↔ CTA
|
||||
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)))
|
||||
}
|
||||
|
||||
// ─── CTA ──────────────────────────────────────────────
|
||||
actionButton
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
// bottom absorber — soaks up remaining height to anchor cards+CTA
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -124,15 +145,24 @@ struct BackupView: View {
|
||||
}
|
||||
.onChange(of: engine.job.status) { status in
|
||||
if status == .completed {
|
||||
// Flash green then revert to black
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
|
||||
// Full reconcile after flash to get accurate counts
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
statusService.refresh(force: true)
|
||||
}
|
||||
} else if case .failed = status {
|
||||
// Reconcile — if all files landed on NAS despite the failure, auto-resolve to .completed
|
||||
Task {
|
||||
await statusService.refreshAndWait(force: true)
|
||||
if statusService.snapshot.needBackup == 0 {
|
||||
engine.resolveSuccess()
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
|
||||
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +211,7 @@ struct BackupView: View {
|
||||
|
||||
// ID that changes on both page swipe and status transition — drives the ring animation
|
||||
private var ringContentID: String {
|
||||
switch engine.job.status {
|
||||
switch resolvedStatus {
|
||||
case .idle, .cancelled: return "\(ringPage)_idle"
|
||||
case .preparing: return "\(ringPage)_preparing"
|
||||
case .running: return "\(ringPage)_running"
|
||||
@@ -238,7 +268,7 @@ struct BackupView: View {
|
||||
// Page 0 content — changes based on backup state
|
||||
@ViewBuilder
|
||||
private var ringMainView: some View {
|
||||
switch engine.job.status {
|
||||
switch resolvedStatus {
|
||||
case .idle, .cancelled:
|
||||
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
||||
VStack(spacing: 8) {
|
||||
@@ -279,7 +309,7 @@ struct BackupView: View {
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.contentTransition(.numericText())
|
||||
.monospacedDigit()
|
||||
Text("\(engine.job.uploadedFiles) of \(engine.job.totalFiles) backed up")
|
||||
Text("Backing up \(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
if let eta = engine.job.eta {
|
||||
@@ -326,15 +356,11 @@ struct BackupView: View {
|
||||
}
|
||||
|
||||
private var ringColor: Color {
|
||||
// Active: always orange
|
||||
if engine.job.status.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
|
||||
// Failed: red
|
||||
if case .failed = engine.job.status { return AppTheme.destructive }
|
||||
// Green only when ALL phone photos are accounted for (needBackup == 0)
|
||||
if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
|
||||
if case .failed = resolvedStatus { return AppTheme.destructive }
|
||||
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
||||
return AppTheme.positive
|
||||
}
|
||||
// Default: neutral
|
||||
return AppTheme.inkQuaternary.opacity(0.55)
|
||||
}
|
||||
|
||||
@@ -608,10 +634,10 @@ struct BackupView: View {
|
||||
|
||||
private var actionButton: some View {
|
||||
Group {
|
||||
switch engine.job.status {
|
||||
switch resolvedStatus {
|
||||
case .idle, .completed, .failed, .cancelled:
|
||||
Button(action: startBackup) {
|
||||
Text(engine.job.status == .idle ? "Start backup" : "Back up again")
|
||||
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle(
|
||||
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
|
||||
|
||||
Reference in New Issue
Block a user