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:
Robin Kutesa
2026-05-17 15:58:54 +03:00
parent 819f2b949e
commit f40cf9ee37
5 changed files with 146 additions and 99 deletions

View File

@@ -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