Files
Kisani/Features/Backup/BackupView.swift

794 lines
33 KiB
Swift
Raw Normal View History

import SwiftUI
import Photos
struct BackupView: View {
@EnvironmentObject var engine: BackupEngine
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
@EnvironmentObject var statusService: BackupStatusService
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>
2026-05-17 17:28:11 +03:00
@EnvironmentObject var coordinator: AutoBackupCoordinator
@StateObject private var vm = BackupViewModel()
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
@Environment(\.scenePhase) private var scenePhase
@State private var showMenu = false
@State private var showDestinationPicker = false
@State private var pickerPath: String = "/"
@State private var ringPage = 0
2026-05-17 15:30:52 +03:00
@State private var ctaFlashGreen = false
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()
// 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, 12)
// Check status
checkStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
// 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)
if statusService.snapshot.needBackup == 0 {
engine.resolveSuccess()
}
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>
2026-05-17 17:28:11 +03:00
// handleReconciliationComplete() fires via Combine and will
// start auto-backup if needed no explicit call required here.
}
}
// Menu button floats above scroll content
Button { showMenu = true } label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.accessibilityLabel("Activity menu")
.padding(.top, 4)
.padding(.trailing, AppTheme.hPad - 4)
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showMenu) {
AppMenuView()
.environmentObject(store)
.environmentObject(lanMonitor)
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
.environmentObject(engine)
}
.sheet(isPresented: $showDestinationPicker, onDismiss: {
guard pickerPath != "/", var conn = store.savedConnection else { return }
conn.remotePath = pickerPath
store.savedConnection = conn
}) {
if let conn = store.savedConnection {
BrowseView(connection: conn, selectedPath: $pickerPath, isPickerMode: true, initialPath: pickerPath)
}
}
.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 {
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>
2026-05-17 17:28:11 +03:00
// Kick off reconciliation and auto-backup on first appear.
coordinator.onActive()
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
}
.onChange(of: scenePhase) { phase in
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>
2026-05-17 17:28:11 +03:00
// Re-check every time the app returns to the foreground.
if phase == .active { coordinator.onActive() }
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
}
.onChange(of: store.savedConnection?.remotePath) { _ in
statusService.refresh(force: true)
}
.onChange(of: store.backupFilter) { _ in
statusService.refresh(force: true)
}
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
.onChange(of: engine.job.status) { status in
if status == .completed {
2026-05-17 15:30:52 +03:00
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
Task {
2026-05-17 15:30:52 +03:00
try? await Task.sleep(nanoseconds: 2_500_000_000)
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
try? await Task.sleep(nanoseconds: 500_000_000)
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
statusService.refresh(force: true)
}
}
// .failed auto-resolve is owned by AutoBackupCoordinator.handleReconciliationComplete.
// It waits for refreshAfterBackup's writeManifest to finish before reconciling, then
// calls engine.resolveSuccess() when needBackup == 0, which re-triggers .completed above.
// Calling refreshAndWait here would race with writeManifest and read a stale NAS manifest.
}
}
// MARK: Logo mark (drawn in code no asset dependency)
// MARK: Ring hero
private var ringHero: some View {
ZStack {
ProgressRing(
progress: engine.job.progress,
lineWidth: 8,
size: 206,
color: ringColor,
backgroundColor: AppTheme.inkQuaternary.opacity(0.6)
)
.shadow(
color: ringColor.opacity(engine.job.status.isActive ? 0.16 : 0),
radius: 22, x: 0, y: 0
)
.animation(.easeInOut(duration: 0.5), value: engine.job.status.isActive)
.accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(engine.job.progress * 100)) percent")
VStack(spacing: 4) {
ringCenterContent
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.id(ringContentID)
}
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID)
}
.gesture(
DragGesture(minimumDistance: 20)
.onEnded { value in
if value.translation.width < 0 {
ringPage = (ringPage + 1) % ringPageCount
} else {
ringPage = (ringPage - 1 + ringPageCount) % ringPageCount
}
}
)
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
// ID that changes on both page swipe and status transition drives the ring animation
private var ringContentID: String {
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>
2026-05-17 17:28:11 +03:00
let enginePart: String
switch resolvedStatus {
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>
2026-05-17 17:28:11 +03:00
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 = ""
}
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>
2026-05-17 17:28:11 +03:00
return "\(ringPage)_\(enginePart)\(coordSuffix)"
}
@ViewBuilder
private var ringCenterContent: some View {
switch ringPage {
case 0:
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>
2026-05-17 17:28:11 +03:00
if engine.job.status.isActive {
ringMainView
} else if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 {
disconnectedRingView
} else {
ringMainView
}
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
case 1: // Need backup
VStack(spacing: 4) {
Text("\(notBackedUpDisplayCount)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
Text("need backup")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
case 2: // Failed
VStack(spacing: 4) {
Text("\(engine.job.failedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("failed")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
default: // Backed up
VStack(spacing: 4) {
Text("\(engine.job.uploadedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.positive)
.contentTransition(.numericText())
.monospacedDigit()
Text("backed up")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
// Page 0 content changes based on backup state
@ViewBuilder
private var ringMainView: some View {
switch resolvedStatus {
case .idle, .cancelled:
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
VStack(spacing: 8) {
Image(systemName: "checkmark")
.font(.system(size: 30, weight: .medium))
.foregroundStyle(AppTheme.positive)
Text("All photos safe")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
} else {
VStack(spacing: 6) {
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
Text("\(statusService.snapshot.needBackup)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
Text(statusService.snapshot.phoneTotal == 0 ? "No photos found" : "need backup")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
case .preparing:
VStack(spacing: 10) {
ProgressView()
.scaleEffect(0.85)
.tint(AppTheme.inkTertiary)
Text("Scanning library…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
case .running, .paused:
VStack(spacing: 5) {
Text(percentText)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
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 {
Text("~\(etaString(eta)) remaining")
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
.padding(.top, 1)
}
}
case .completed:
VStack(spacing: 8) {
Image(systemName: "checkmark")
.font(.system(size: 30, weight: .medium))
.foregroundStyle(AppTheme.positive)
Text("\(engine.job.uploadedFiles) photos backed up")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
case .failed:
VStack(spacing: 8) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 30, weight: .light))
.foregroundStyle(AppTheme.destructive)
Text("Backup failed")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.destructive.opacity(0.8))
}
}
}
// MARK: Page indicator
private var pageIndicator: some View {
HStack(spacing: 5) {
ForEach(0..<ringPageCount, id: \.self) { i in
Capsule(style: .continuous)
.fill(i == ringPage ? AppTheme.interactive : AppTheme.inkQuaternary)
.frame(width: i == ringPage ? 18 : 5, height: 5)
.animation(.spring(response: 0.3, dampingFraction: 0.75), value: ringPage)
}
}
}
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>
2026-05-17 17:28:11 +03:00
// 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 }
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>
2026-05-17 17:28:11 +03:00
// 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)
}
2026-05-17 15:30:52 +03:00
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
return AppTheme.positive
}
2026-05-17 15:30:52 +03:00
return AppTheme.inkQuaternary.opacity(0.55)
}
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
// MARK: Compact stats strip
2026-05-17 15:30:52 +03:00
// Invariant: alreadySafe + needBackup == phoneTotal always.
// During active backup show optimistic count (only newly uploaded, not skipped
// skipped files are already counted inside snapshot.alreadySafe from the last reconcile).
// Live NAS Archive: grows by 1 with each confirmed upload this session.
// Skipped files (already on NAS) are NOT added if nasArchiveTotal came from
// a directory scan they're already counted; if from manifest they weren't tracked
// but they're present on disk regardless.
private var nasArchiveDisplayCount: Int {
let base = statusService.snapshot.nasArchiveTotal
if engine.job.status.isActive {
return base + engine.job.uploadedFiles
}
return base
}
2026-05-17 15:30:52 +03:00
private var alreadySafeDisplayCount: Int {
let total = statusService.snapshot.phoneTotal
guard total > 0 else { return 0 }
let base = statusService.snapshot.alreadySafe
if engine.job.status.isActive {
// Uploaded = newly confirmed on NAS this session.
// Skipped = fileExists returned true already on NAS but wasn't in manifest.
// Both are now confirmed safe; count them both optimistically.
return min(base + engine.job.uploadedFiles + engine.job.skippedFiles, total)
}
return min(base, total)
}
2026-05-17 15:30:52 +03:00
private var notBackedUpDisplayCount: Int {
let total = statusService.snapshot.phoneTotal
guard total > 0 else { return 0 }
if engine.job.status.isActive {
return max(0, total - alreadySafeDisplayCount)
}
2026-05-17 15:30:52 +03:00
return statusService.snapshot.needBackup
}
private var statsStrip: some View {
VStack(spacing: 7) {
HStack(spacing: 0) {
compactStat(
"\(nasArchiveDisplayCount)",
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
label: "NAS Archive",
color: AppTheme.interactive
)
thinDivider
compactStat(
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
"\(statusService.snapshot.phoneTotal)",
label: "On iPhone",
color: AppTheme.inkSecondary
)
thinDivider
compactStat(
"\(notBackedUpDisplayCount)",
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
label: "Need Backup",
color: AppTheme.inkSecondary
)
thinDivider
compactStat(
"\(alreadySafeDisplayCount)",
label: "Already Safe",
color: AppTheme.positive
)
}
.frame(height: 48)
.background(AppTheme.surfaceSunken.opacity(0.85))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
pageIndicator
}
}
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
private var checkStatusRow: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
if statusService.isRefreshing {
ProgressView()
.scaleEffect(0.5)
.tint(AppTheme.inkQuaternary)
Text("Checking…")
.font(.system(size: 10, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
} else if let checked = statusService.snapshot.lastCheckedAt {
Image(systemName: statusService.snapshot.connectionState == .offline
? "wifi.slash" : "checkmark.circle")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(statusService.snapshot.connectionState == .offline
? .orange : AppTheme.inkQuaternary)
Text(checked, style: .relative)
.font(.system(size: 10, weight: .regular))
.foregroundColor(AppTheme.inkQuaternary)
+ Text(" ago")
.font(.system(size: 10, weight: .regular))
.foregroundColor(AppTheme.inkQuaternary)
}
Spacer()
Button {
statusService.refresh(force: true)
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
.frame(height: 16)
HStack(spacing: 4) {
Image(systemName: syncStatusIcon)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(syncStatusColor)
Text(syncStatusLabel)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(syncStatusColor)
Add live backup status reconciliation with NAS manifest BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver): - Loads cached BackupStatusSnapshot instantly from UserDefaults on init - Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare → enforce invariant (alreadySafe ≤ phoneTotal), persist result - Debounced (30s) for photo library changes; force=true bypasses debounce - If NAS unreachable: keeps last cached numbers, marks connectionState = .offline - PHPhotoLibraryChangeObserver triggers refresh on any library change BackupManifest (new): - Stored at {remotePath}/.kisani.json on NAS - Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries - Built from directory listing if manifest missing (bootstrap for existing backups) - merged/updated after each backup run via NASTransferProtocol.writeData BackupStatusSnapshot (new): - Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal, lastCheckedAt, connectionState - Invariant enforced in service: alreadySafe = min(safe, phoneTotal) Protocol / services: - NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String) - SMBService: implements writeData via temp file + SMBClient.upload - SFTPService: implements writeData via SFTP ByteBuffer write BackupEngine: - Tracks ManifestEntry per successful upload during backup loop - After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:) which applies optimistic UI update then writes manifest + triggers reconcile BackupView: - Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount) - Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe" - Live refresh triggers: .task (force), scenePhase.active (force), nasReachable change (force), remotePath change (force), backup completed (+2s) - Subtle status row below stats: "Checking…" spinner or "Updated X ago" with wifi-slash icon when NAS offline; tap refresh button for forced reconcile - AppMenuView sheet now correctly passes engine EnvironmentObject BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess) Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 12:51:03 +03:00
}
.animation(.easeInOut(duration: 0.2), value: syncStatusLabel)
}
}
private var syncStatusIcon: String {
if engine.job.status == .preparing { return "magnifyingglass" }
if engine.job.status.isActive { return "arrow.triangle.2.circlepath" }
if engine.job.status == .paused { return "pause.fill" }
if statusService.isRefreshing || coordinator.phase == .checking { return "magnifyingglass" }
if statusService.snapshot.connectionState == .offline { return "wifi.slash" }
return "stop.fill"
}
private var syncStatusColor: Color {
if engine.job.status.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
if engine.job.status == .paused { return Color(red: 1.0, green: 0.58, blue: 0.0) }
if statusService.snapshot.connectionState == .offline { return AppTheme.destructive }
return AppTheme.inkQuaternary
}
private var syncStatusLabel: String {
if engine.job.status == .preparing { return "Checking" }
if engine.job.status.isActive { return "Running" }
if engine.job.status == .paused { return "Paused" }
if statusService.isRefreshing || coordinator.phase == .checking { return "Checking" }
if statusService.snapshot.connectionState == .offline { return "Offline" }
return "Stopped"
}
private func compactStat(_ value: String, label: String, color: Color) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(color)
.monospacedDigit()
Text(label)
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
.tracking(0.2)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
private var thinDivider: some View {
Rectangle()
.fill(AppTheme.separator.opacity(0.5))
.frame(width: 0.5, height: 18)
}
// MARK: Destination row
private var destinationRow: some View {
Button(action: {
pickerPath = store.savedConnection?.remotePath ?? "/"
showDestinationPicker = true
}) {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 34, height: 34)
Image(systemName: "folder.fill")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
let path = store.savedConnection?.remotePath ?? "/"
let folderName = path == "/" ? "Not set" : (path as NSString).lastPathComponent
VStack(alignment: .leading, spacing: 1) {
Text(folderName)
.font(AppTheme.headline())
.foregroundStyle(path == "/" ? AppTheme.inkTertiary : AppTheme.ink)
Text("Backup folder")
.font(AppTheme.micro(10))
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
.disabled(store.savedConnection == nil)
}
private var nasStatusDotColor: Color {
switch engine.job.status {
case .running, .preparing, .paused:
return Color(red: 1.0, green: 0.58, blue: 0.0)
default:
switch lanMonitor.nasReachable {
case true: return AppTheme.positive
case false: return AppTheme.destructive
case nil: return AppTheme.inkQuaternary
}
}
}
private var nasStatusLabel: String {
switch engine.job.status {
case .running: return "Backing up"
case .preparing: return "Preparing"
case .paused: return "Paused"
default:
return lanMonitor.nasReachable == false ? "Offline" : "Ready"
}
}
// MARK: NAS status row
private var nasStatusRow: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 34, height: 34)
Image(systemName: "externaldrive")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
if let conn = store.savedConnection {
let friendlyName = conn.displayName == conn.host ? "Home Server" : conn.displayName
VStack(alignment: .leading, spacing: 1) {
Text(friendlyName)
.font(AppTheme.headline())
.foregroundStyle(AppTheme.ink)
Text("\(conn.nasProtocol.rawValue) · \(conn.host)")
.font(AppTheme.micro(10))
.foregroundStyle(AppTheme.inkTertiary)
}
} else {
Text("No storage connected")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
HStack(spacing: 4) {
Circle()
.fill(nasStatusDotColor)
.frame(width: 6, height: 6)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusDotColor == AppTheme.positive)
Text(nasStatusLabel)
.font(AppTheme.micro())
.foregroundStyle(nasStatusDotColor)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel)
}
if lanMonitor.nasReachable == false {
Text(statusService.refreshError ?? "Unreachable")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.destructive.opacity(0.7))
.lineLimit(2)
.multilineTextAlignment(.trailing)
} else if lanMonitor.nasReachable == true && !engine.job.status.isActive {
Text("Connected")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.positive.opacity(0.7))
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
}
// MARK: Photos access nudge
private var photosAccessRow: some View {
Button(action: { Task { await vm.requestPhotosAccess() } }) {
HStack(spacing: 10) {
Image(systemName: "photo.on.rectangle")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.destructive)
.frame(width: 20)
Text("Grant photo library access to enable backup")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(AppTheme.destructive.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
}
// MARK: Action button
private var actionButton: some View {
Group {
switch resolvedStatus {
case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) {
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
}
.buttonStyle(PrimaryButtonStyle(
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
))
.accessibilityHint("Starts backing up your photos to the NAS")
case .running:
Button { engine.pause() } label: { Text("Pause") }
.buttonStyle(GhostButtonStyle())
.accessibilityLabel("Pause backup")
case .paused:
HStack(spacing: 10) {
Button { engine.resume() } label: { Text("Resume") }
.buttonStyle(PrimaryButtonStyle())
.accessibilityLabel("Resume backup")
Button { engine.cancel() } label: { Text("Cancel") }
.buttonStyle(GhostButtonStyle())
.frame(width: 90)
.accessibilityLabel("Cancel backup")
}
case .preparing:
HStack(spacing: 8) {
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
Text("Preparing…")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
}
.frame(height: 50)
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
}
private func startBackup() {
guard let conn = store.savedConnection else { return }
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
}
private func etaString(_ seconds: TimeInterval) -> String {
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
}
}