- 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>
647 lines
26 KiB
Swift
647 lines
26 KiB
Swift
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"
|
||
}
|
||
}
|