Files
Kisani/Features/Backup/BackupView.swift
Robin Kutesa a077964d75 Fix AppMenuView Connection Status UX and add smart sync dashboard
- Wi-Fi row: title always "Wi-Fi", subtitle shows SSID or Cellular/Not connected;
  badge is Connected (green) / Cellular (amber) / Offline (muted) — no more
  contradictory "No Wi-Fi" title with "Wi-Fi connected" subtitle
- NAS badge: Reachable now amber (TCP ping only, not auth-confirmed), Offline red,
  Unknown muted — removes misleading green on unverified NAS connectivity
- Error rows are now tappable; chevron affordance + ScaleButtonStyle; opens detail
  sheet with date, counts, duration, NAS host, trigger type, and suggested fix steps
- Sync Activity section: Clear button with confirmation alert (warns about error count
  if errors exist), calls store.clearHistory()
- BackupViewModel: compareWithNAS() computes accurate pre-scan alreadySafeCount /
  notBackedUpCount by matching lowercased filenames against NAS directory listing
- BackupView: ring shows state-based content (idle: not-backed-up count, running: %,
  completed: checkmark), pill-style page indicator, display counts update live during
  backup run, loadNASAndCompare() called on appear

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 11:54:43 +03:00

647 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import Photos
struct BackupView: View {
@EnvironmentObject var engine: BackupEngine
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@StateObject private var vm = BackupViewModel()
@State private var showMenu = false
@State private var showDestinationPicker = false
@State private var pickerPath: String = "/"
@State private var ringPage = 0
@State private var nasFileCount: Int? = nil
private let ringPageCount = 4
var body: some View {
ZStack(alignment: .topTrailing) {
AppTheme.background.ignoresSafeArea()
// Menu aligned with kisani. wordmark
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)
VStack(spacing: 0) {
// Brand
VStack(spacing: 10) {
Text("kisani.")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(AppTheme.ink)
.kerning(2)
kisaniLogoMark
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
// flex zone brand ring (absorbs screen-size variance)
Spacer(minLength: 24).frame(maxHeight: 48)
// Ring
ringHero
.padding(.bottom, 44)
// Stats
statsStrip
.padding(.horizontal, AppTheme.hPad)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
// flex zone stats cards
Spacer(minLength: 20).frame(maxHeight: 48)
// Storage card
nasStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
// Source card
destinationRow
.padding(.horizontal, AppTheme.hPad)
// flex zone cards CTA
Spacer(minLength: 20).frame(maxHeight: 32)
// Photos access nudge
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
photosAccessRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 8)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// CTA
actionButton
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
// bottom absorber soaks up remaining height to anchor cards+CTA
Spacer(minLength: 0)
}
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showMenu) {
AppMenuView()
.environmentObject(store)
.environmentObject(lanMonitor)
}
.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 {
vm.loadPhotoCount(filter: store.backupFilter)
await loadNASAndCompare()
}
}
// MARK: Logo mark (drawn in code no asset dependency)
private var kisaniLogoMark: some View {
VStack(spacing: 0) {
driveRow
AppTheme.ink.frame(height: 1.5)
driveRow
AppTheme.ink.frame(height: 1.5)
driveRow
}
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 11, style: .continuous)
.strokeBorder(AppTheme.ink, lineWidth: 2.5)
)
.frame(width: 66, height: 66)
.shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 2)
}
private var driveRow: some View {
HStack(spacing: 0) {
Spacer()
Circle()
.fill(AppTheme.ink)
.frame(width: 7, height: 7)
.padding(.trailing, 7)
}
.frame(maxWidth: .infinity)
.frame(height: 18)
.background(AppTheme.surfaceRaised)
}
// 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 {
switch engine.job.status {
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"
}
}
@ViewBuilder
private var ringCenterContent: some View {
switch ringPage {
case 0:
ringMainView
case 1: // Not backed up
VStack(spacing: 4) {
Text("\(notBackedUpDisplayCount)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("not backed up")
.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 engine.job.status {
case .idle, .cancelled:
if vm.totalPhotoCount > 0 && vm.notBackedUpCount == 0 {
// All phone photos already exist in NAS
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) {
Text("\(vm.notBackedUpCount)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text(vm.totalPhotoCount == 0 ? "No photos found" : "not backed up")
.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("\(engine.job.uploadedFiles) of \(engine.job.totalFiles) backed up")
.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)
}
}
}
private var ringColor: Color {
switch engine.job.status {
case .completed: return AppTheme.positive
case .failed: return AppTheme.destructive
case .running, .paused,
.preparing: return Color(red: 1.0, green: 0.58, blue: 0.0)
default: return AppTheme.interactive.opacity(0.28)
}
}
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
// MARK: Compact stats strip
// Photos from the phone set that are NOT yet in the NAS.
// Idle: derived from NAS comparison scan.
// Active: live remaining from engine job counters.
private var notBackedUpDisplayCount: Int {
switch engine.job.status {
case .running, .paused, .preparing:
return max(0, engine.job.totalFiles - engine.job.uploadedFiles
- engine.job.skippedFiles - engine.job.failedFiles)
case .completed: return 0
default: return vm.notBackedUpCount
}
}
// Photos from the phone set that ARE already safe in the NAS.
// Grows during backup as uploads + engine-skipped files are confirmed present.
private var alreadySafeDisplayCount: Int {
switch engine.job.status {
case .running, .paused, .preparing, .completed:
return vm.alreadySafeCount + engine.job.uploadedFiles + engine.job.skippedFiles
default:
return vm.alreadySafeCount
}
}
private var statsStrip: some View {
VStack(spacing: 7) {
HStack(spacing: 0) {
compactStat(
nasFileCount.map { "\($0)" } ?? "",
label: "In NAS",
color: AppTheme.interactive
)
thinDivider
compactStat(
"\(vm.totalPhotoCount)",
label: "On iPhone",
color: AppTheme.inkSecondary
)
thinDivider
compactStat(
"\(notBackedUpDisplayCount)",
label: "Not Backed Up",
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
}
}
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)
}
private func loadNASAndCompare() async {
guard let conn = store.savedConnection else { return }
do {
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
try await s.connect(to: conn.host, port: conn.port,
username: conn.username, password: conn.password)
let items = try await s.listDirectory(at: conn.remotePath)
nasFileCount = items.filter { !$0.isDirectory }.count
vm.compareWithNAS(nasItems: items)
s.disconnect()
} catch {
nasFileCount = nil
// On failure: notBackedUpCount stays at totalPhotoCount (conservative show all as pending)
}
}
// 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("Unreachable")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.destructive.opacity(0.7))
} 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 engine.job.status {
case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) {
Text(engine.job.status == .completed ? "Back up again" : "Start backup")
}
.buttonStyle(PrimaryButtonStyle(
color: engine.job.status == .completed ? 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"
}
}