feat: automatic backup on launch, foreground, and photo library change

AutoBackupCoordinator (new):
- @MainActor singleton that owns all auto-backup decisions
- Phase model: idle / checking / autoBackingUp / disconnected
  - idle:          nothing pending or destination not set
  - checking:      reconciliation in progress before decision
  - autoBackingUp: coordinator-triggered BackupEngine run active
  - disconnected:  NAS offline, pending count queued for retry
- Combine subscription on BackupStatusService.$isRefreshing fires
  handleReconciliationComplete() on every reconcile completion — the
  single entry point for all auto-backup decisions
- Combine subscription on LANMonitor.$nasReachable fires
  handleNASReachable() which retries only when phase == .disconnected
- Guards: autoBackupEnabled, quiet hours, chargingOnlyMode + battery state
- canAutoBackup also blocks when engine.job.status.isActive to prevent
  double-start from concurrent photo library change + foreground events

BackupView:
- .task + .onChange(of: scenePhase) now call coordinator.onActive() instead
  of statusService.refresh() directly — coordinator decides whether to also
  start a backup after reconciliation completes
- ringContentID includes coordinator phase suffix → ring animates on
  checking/disconnected transitions
- ringCenterContent case 0: when engine is idle, shows:
  - disconnectedRingView if phase == .disconnected and needBackup > 0
    (wifi.slash icon, "Waiting for NAS", queued count)
  - checkingRingView if phase == .checking (spinner, "Checking…")
  - existing ringMainView otherwise
- ringColor: dim orange (0.35 opacity) when disconnected with pending items
- Pull-to-refresh reconciliation triggers auto-backup via Combine chain

ConnectionStore:
- autoBackupEnabled: Bool, default true, UserDefaults-persisted

SettingsView:
- AUTO BACKUP section at top with autoBackupEnabled toggle

BackgroundTaskManager:
- BGProcessingTask: quiet-hours guard, requiresExternalPower mirrors
  chargingOnlyMode, expiration handler calls engine.cancel() for clean exit
- BGAppRefreshTask: lightweight refresh only (no upload in background refresh)
- Both tasks reschedule themselves before doing any work

AppDelegate:
- UIDevice.current.isBatteryMonitoringEnabled = true for battery checks

NASBackupApp:
- AutoBackupCoordinator.shared injected as @StateObject + environmentObject

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 17:28:11 +03:00
parent 0f62268af1
commit 5b68fde62f
8 changed files with 337 additions and 30 deletions

View File

@@ -6,6 +6,7 @@ struct BackupView: View {
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@EnvironmentObject var statusService: BackupStatusService
@EnvironmentObject var coordinator: AutoBackupCoordinator
@StateObject private var vm = BackupViewModel()
@Environment(\.scenePhase) private var scenePhase
@@ -92,10 +93,11 @@ struct BackupView: View {
}
.refreshable {
await statusService.refreshAndWait(force: true)
// After reconciliation, clear stale failure if counts say all safe
if statusService.snapshot.needBackup == 0 {
engine.resolveSuccess()
}
// handleReconciliationComplete() fires via Combine and will
// start auto-backup if needed no explicit call required here.
}
}
@@ -132,13 +134,18 @@ 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)
.task {
statusService.refresh(force: true)
// Kick off reconciliation and auto-backup on first appear.
coordinator.onActive()
}
.onChange(of: scenePhase) { phase in
if phase == .active { statusService.refresh(force: true) }
// Re-check every time the app returns to the foreground.
if phase == .active { coordinator.onActive() }
}
.onChange(of: lanMonitor.nasReachable) { reachable in
if reachable == true { statusService.refresh(force: true) }
// Belt-and-suspenders: coordinator handles this via Combine,
// but an explicit refresh here ensures the stats strip updates
// even when the coordinator's phase guard is not .disconnected.
if reachable == true { statusService.refresh(force: false) }
}
.onChange(of: store.savedConnection?.remotePath) { _ in
statusService.refresh(force: true)
@@ -211,21 +218,41 @@ struct BackupView: View {
// ID that changes on both page swipe and status transition drives the ring animation
private var ringContentID: String {
let enginePart: String
switch resolvedStatus {
case .idle, .cancelled: return "\(ringPage)_idle"
case .preparing: return "\(ringPage)_preparing"
case .running: return "\(ringPage)_running"
case .paused: return "\(ringPage)_paused"
case .completed: return "\(ringPage)_done"
case .failed: return "\(ringPage)_failed"
case .idle, .cancelled: enginePart = "idle"
case .preparing: enginePart = "prep"
case .running: enginePart = "run"
case .paused: enginePart = "pause"
case .completed: enginePart = "done"
case .failed: enginePart = "fail"
}
// Include coordinator phase so ring animates on checking/disconnected transitions
let coordSuffix: String
switch coordinator.phase {
case .checking: coordSuffix = "_chk"
case .disconnected: coordSuffix = "_disc"
case .autoBackingUp: coordSuffix = "_auto"
case .idle: coordSuffix = ""
}
return "\(ringPage)_\(enginePart)\(coordSuffix)"
}
@ViewBuilder
private var ringCenterContent: some View {
switch ringPage {
case 0:
ringMainView
// Engine activity always takes priority.
// Coordinator states are shown only when the engine is idle.
if engine.job.status.isActive {
ringMainView
} else if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 {
disconnectedRingView
} else if coordinator.phase == .checking {
checkingRingView
} else {
ringMainView
}
case 1: // Need backup
VStack(spacing: 4) {
@@ -355,9 +382,45 @@ struct BackupView: View {
}
}
// MARK: Coordinator ring states (shown when engine is idle)
@ViewBuilder
private var checkingRingView: some View {
VStack(spacing: 10) {
ProgressView()
.scaleEffect(0.85)
.tint(AppTheme.inkTertiary)
Text("Checking…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
@ViewBuilder
private var disconnectedRingView: some View {
VStack(spacing: 8) {
Image(systemName: "wifi.slash")
.font(.system(size: 26, weight: .light))
.foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.75))
Text("Waiting for NAS")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
if coordinator.pendingAtDisconnect > 0 {
Text("\(coordinator.pendingAtDisconnect) queued")
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary)
.contentTransition(.numericText())
}
}
}
private var ringColor: Color {
if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
if case .failed = resolvedStatus { return AppTheme.destructive }
// Disconnected with pending items: dim orange pulse
if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 {
return Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.35)
}
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
return AppTheme.positive
}

View File

@@ -12,6 +12,7 @@ struct SettingsView: View {
ScrollView {
VStack(spacing: AppTheme.sectionGap) {
appearanceSection
autoBackupSection
LANTriggerSection()
networkStatusSection
mediaSection
@@ -41,6 +42,26 @@ struct SettingsView: View {
}
}
// MARK: Auto Backup
private var autoBackupSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("AUTO BACKUP")
VStack(spacing: 0) {
ToggleRow(
icon: "arrow.clockwise.icloud",
title: "Auto backup",
subtitle: "Back up new photos automatically when the app opens",
isOn: $store.autoBackupEnabled
)
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}
// MARK: Appearance
private var appearanceSection: some View {