Translates the React KisaniCircle design to SwiftUI: three concentric thin rings (outer slate progress, middle static gray, inner green progress) replace the old single ProgressRing. All ring center text updated to ultraLight 46pt numbers with 8pt monospaced uppercase labels. Adds ringCheckmark (22px circle + tiny checkmark) for completed/all-safe states. Updates checking, disconnected, failed center views to match. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
854 lines
35 KiB
Swift
854 lines
35 KiB
Swift
import SwiftUI
|
|
import Photos
|
|
|
|
struct BackupView: View {
|
|
@EnvironmentObject var engine: BackupEngine
|
|
@EnvironmentObject var store: ConnectionStore
|
|
@EnvironmentObject var lanMonitor: LANMonitor
|
|
@EnvironmentObject var statusService: BackupStatusService
|
|
@EnvironmentObject var coordinator: AutoBackupCoordinator
|
|
@StateObject private var vm = BackupViewModel()
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
@State private var showMenu = false
|
|
@State private var showDestinationPicker = false
|
|
@State private var pickerPath: String = "/"
|
|
@State private var ringPage = 0
|
|
@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()
|
|
}
|
|
// 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)
|
|
.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 {
|
|
// Kick off reconciliation and auto-backup on first appear.
|
|
coordinator.onActive()
|
|
}
|
|
.onChange(of: scenePhase) { phase in
|
|
// Re-check every time the app returns to the foreground.
|
|
if phase == .active { coordinator.onActive() }
|
|
}
|
|
.onChange(of: store.savedConnection?.remotePath) { _ in
|
|
statusService.refresh(force: true)
|
|
}
|
|
.onChange(of: store.backupFilter) { _ in
|
|
statusService.refresh(force: true)
|
|
}
|
|
.onChange(of: engine.job.status) { status in
|
|
if status == .completed {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
|
|
Task {
|
|
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)
|
|
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 {
|
|
KisaniRingView(outerProgress: outerRingProgress, innerProgress: innerRingProgress)
|
|
.accessibilityLabel("Backup progress")
|
|
.accessibilityValue("\(Int(innerRingProgress * 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 {
|
|
let enginePart: String
|
|
switch resolvedStatus {
|
|
case .idle, .cancelled: enginePart = "idle"
|
|
case .preparing: enginePart = "prep"
|
|
case .running: enginePart = "run"
|
|
case .paused: enginePart = "pause"
|
|
case .completed: enginePart = "done"
|
|
case .failed: enginePart = "fail"
|
|
}
|
|
// Include coordinator phase so ring animates on checking/disconnected transitions
|
|
let coordSuffix: String
|
|
switch coordinator.phase {
|
|
case .checking: coordSuffix = "_chk"
|
|
case .disconnected: coordSuffix = "_disc"
|
|
case .autoBackingUp: coordSuffix = "_auto"
|
|
case .idle: coordSuffix = ""
|
|
}
|
|
return "\(ringPage)_\(enginePart)\(coordSuffix)"
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var ringCenterContent: some View {
|
|
switch ringPage {
|
|
case 0:
|
|
if engine.job.status.isActive {
|
|
ringMainView
|
|
} else if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 {
|
|
disconnectedRingView
|
|
} else {
|
|
ringMainView
|
|
}
|
|
|
|
case 1: // Need backup
|
|
VStack(spacing: 4) {
|
|
Text("\(notBackedUpDisplayCount)")
|
|
.font(.system(size: 46, weight: .ultraLight))
|
|
.foregroundStyle(AppTheme.ink)
|
|
.contentTransition(.numericText())
|
|
.kerning(-2)
|
|
Text("NEED BACKUP")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(2).opacity(0.75)
|
|
}
|
|
|
|
case 2: // Failed
|
|
VStack(spacing: 4) {
|
|
Text("\(engine.job.failedFiles)")
|
|
.font(.system(size: 46, weight: .ultraLight))
|
|
.foregroundStyle(engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink)
|
|
.contentTransition(.numericText())
|
|
.kerning(-2)
|
|
Text("FAILED")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(2).opacity(0.75)
|
|
}
|
|
|
|
default: // Backed up
|
|
VStack(spacing: 4) {
|
|
Text("\(engine.job.uploadedFiles)")
|
|
.font(.system(size: 46, weight: .ultraLight))
|
|
.foregroundStyle(AppTheme.positive)
|
|
.contentTransition(.numericText())
|
|
.kerning(-2)
|
|
Text("BACKED UP")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(2).opacity(0.75)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Page 0 content — changes based on backup state
|
|
@ViewBuilder
|
|
private var ringMainView: some View {
|
|
switch resolvedStatus {
|
|
case .idle, .cancelled:
|
|
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
|
VStack(spacing: 8) {
|
|
ringCheckmark
|
|
Text("ALL PHOTOS SAFE")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(2).opacity(0.75)
|
|
}
|
|
} else {
|
|
VStack(spacing: 6) {
|
|
Text("\(statusService.snapshot.needBackup)")
|
|
.font(.system(size: 46, weight: .ultraLight))
|
|
.foregroundStyle(AppTheme.ink)
|
|
.contentTransition(.numericText())
|
|
.kerning(-2)
|
|
Text(statusService.snapshot.phoneTotal == 0 ? "NO PHOTOS FOUND" : "NEED BACKUP")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(2).opacity(0.75)
|
|
}
|
|
}
|
|
|
|
case .preparing:
|
|
VStack(spacing: 10) {
|
|
ProgressView()
|
|
.scaleEffect(0.85)
|
|
.tint(AppTheme.inkTertiary)
|
|
Text("SCANNING LIBRARY")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(2).opacity(0.75)
|
|
}
|
|
|
|
case .running, .paused:
|
|
VStack(spacing: 5) {
|
|
Text(percentText)
|
|
.font(.system(size: 46, weight: .ultraLight))
|
|
.foregroundStyle(AppTheme.ink)
|
|
.contentTransition(.numericText())
|
|
.kerning(-2)
|
|
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) OF \(engine.job.totalFiles)")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.tracking(1.5).opacity(0.75)
|
|
if let eta = engine.job.eta {
|
|
Text("~\(etaString(eta)) remaining")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(1.5).opacity(0.6)
|
|
.padding(.top, 1)
|
|
}
|
|
}
|
|
|
|
case .completed:
|
|
VStack(spacing: 8) {
|
|
ringCheckmark
|
|
Text("ALL DONE")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(2).opacity(0.75)
|
|
}
|
|
|
|
case .failed:
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.circle")
|
|
.font(.system(size: 26, weight: .light))
|
|
.foregroundStyle(AppTheme.destructive)
|
|
Text("BACKUP FAILED")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.destructive.opacity(0.8))
|
|
.tracking(2).opacity(0.75)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(2).opacity(0.75)
|
|
}
|
|
}
|
|
|
|
@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: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.tracking(2).opacity(0.75)
|
|
if coordinator.pendingAtDisconnect > 0 {
|
|
Text("\(coordinator.pendingAtDisconnect) QUEUED")
|
|
.font(.system(size: 8, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.tracking(2).opacity(0.75)
|
|
.contentTransition(.numericText())
|
|
}
|
|
}
|
|
}
|
|
|
|
private var ringCheckmark: some View {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(AppTheme.positive, lineWidth: 1)
|
|
.frame(width: 22, height: 22)
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 8, weight: .medium))
|
|
.foregroundStyle(AppTheme.positive)
|
|
}
|
|
}
|
|
|
|
private var outerRingProgress: CGFloat {
|
|
let nas = CGFloat(nasArchiveDisplayCount)
|
|
let phone = CGFloat(max(1, statusService.snapshot.phoneTotal))
|
|
return min(nas / (nas + phone), 1)
|
|
}
|
|
|
|
private var innerRingProgress: CGFloat {
|
|
CGFloat(alreadySafeDisplayCount) / CGFloat(max(1, statusService.snapshot.phoneTotal))
|
|
}
|
|
|
|
private var ringColor: Color {
|
|
if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
|
|
if case .failed = resolvedStatus { return AppTheme.destructive }
|
|
// Disconnected with pending items: dim orange pulse
|
|
if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 {
|
|
return Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.35)
|
|
}
|
|
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
|
return AppTheme.positive
|
|
}
|
|
return AppTheme.inkQuaternary.opacity(0.55)
|
|
}
|
|
|
|
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
|
|
|
|
// MARK: — Compact stats strip
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
return statusService.snapshot.needBackup
|
|
}
|
|
|
|
private var statsStrip: some View {
|
|
VStack(spacing: 7) {
|
|
HStack(spacing: 0) {
|
|
compactStat(
|
|
"\(nasArchiveDisplayCount)",
|
|
label: "NAS Archive",
|
|
color: AppTheme.interactive
|
|
)
|
|
thinDivider
|
|
compactStat(
|
|
"\(statusService.snapshot.phoneTotal)",
|
|
label: "On iPhone",
|
|
color: AppTheme.inkSecondary
|
|
)
|
|
thinDivider
|
|
compactStat(
|
|
"\(notBackedUpDisplayCount)",
|
|
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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
.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"
|
|
}
|
|
}
|
|
|
|
// MARK: — Ring
|
|
|
|
private struct KisaniRingView: View {
|
|
let outerProgress: CGFloat
|
|
let innerProgress: CGFloat
|
|
|
|
private let track = Color(red: 0.92, green: 0.92, blue: 0.92)
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Outer track
|
|
Circle().stroke(track, lineWidth: 1).padding(10)
|
|
// Outer progress (slate, animates)
|
|
Circle().trim(from: 0, to: outerProgress)
|
|
.stroke(
|
|
Color(red: 0.58, green: 0.64, blue: 0.73),
|
|
style: StrokeStyle(lineWidth: 1, lineCap: .round)
|
|
)
|
|
.rotationEffect(.degrees(-90))
|
|
.padding(10)
|
|
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: outerProgress)
|
|
|
|
// Middle static ring (gray)
|
|
Circle().stroke(Color(red: 0.78, green: 0.78, blue: 0.78), lineWidth: 1).padding(24)
|
|
|
|
// Inner track
|
|
Circle().stroke(track, lineWidth: 1.5).padding(38)
|
|
// Inner progress (green, animates)
|
|
Circle().trim(from: 0, to: innerProgress)
|
|
.stroke(
|
|
AppTheme.positive,
|
|
style: StrokeStyle(lineWidth: 1.5, lineCap: .round)
|
|
)
|
|
.rotationEffect(.degrees(-90))
|
|
.padding(38)
|
|
.animation(.spring(response: 0.7, dampingFraction: 0.8), value: innerProgress)
|
|
}
|
|
.frame(width: 210, height: 210)
|
|
}
|
|
}
|