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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user