302 lines
11 KiB
Swift
302 lines
11 KiB
Swift
|
|
import SwiftUI
|
||
|
|
import Photos
|
||
|
|
|
||
|
|
struct BackupView: View {
|
||
|
|
@EnvironmentObject var engine: BackupEngine
|
||
|
|
@EnvironmentObject var store: ConnectionStore
|
||
|
|
@StateObject private var vm = BackupViewModel()
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
ZStack {
|
||
|
|
Color.white.ignoresSafeArea()
|
||
|
|
|
||
|
|
ScrollView {
|
||
|
|
VStack(spacing: 24) {
|
||
|
|
// Ring + counts
|
||
|
|
ringSection
|
||
|
|
|
||
|
|
// NAS status
|
||
|
|
nasStatusRow
|
||
|
|
|
||
|
|
// Options card
|
||
|
|
optionsCard
|
||
|
|
|
||
|
|
// Start / pause button
|
||
|
|
actionButton
|
||
|
|
}
|
||
|
|
.padding(.horizontal, 20)
|
||
|
|
.padding(.top, 20)
|
||
|
|
.padding(.bottom, 40)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.task {
|
||
|
|
vm.loadPhotoCount(filter: store.backupFilter)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: Ring
|
||
|
|
|
||
|
|
private var ringSection: some View {
|
||
|
|
VStack(spacing: 16) {
|
||
|
|
ZStack {
|
||
|
|
ProgressRing(
|
||
|
|
progress: engine.job.progress,
|
||
|
|
lineWidth: 14,
|
||
|
|
size: 200,
|
||
|
|
color: ringColor
|
||
|
|
)
|
||
|
|
|
||
|
|
VStack(spacing: 4) {
|
||
|
|
Text(percentText)
|
||
|
|
.font(.system(size: 36, weight: .bold))
|
||
|
|
.foregroundColor(AppTheme.textPrimary)
|
||
|
|
|
||
|
|
if case .running = engine.job.status {
|
||
|
|
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) / \(engine.job.totalFiles)")
|
||
|
|
.font(AppTheme.bodyFont)
|
||
|
|
.foregroundColor(AppTheme.textSecondary)
|
||
|
|
if let eta = engine.job.eta {
|
||
|
|
Text("~\(etaString(eta))")
|
||
|
|
.font(AppTheme.captionFont)
|
||
|
|
.foregroundColor(AppTheme.textTertiary)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Text("\(vm.totalPhotoCount) photos")
|
||
|
|
.font(AppTheme.bodyFont)
|
||
|
|
.foregroundColor(AppTheme.textSecondary)
|
||
|
|
Text("0 backed up")
|
||
|
|
.font(AppTheme.captionFont)
|
||
|
|
.foregroundColor(AppTheme.textTertiary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Completion summary
|
||
|
|
if case .completed = engine.job.status {
|
||
|
|
completionSummary
|
||
|
|
}
|
||
|
|
|
||
|
|
// Progress detail row
|
||
|
|
if case .running = engine.job.status {
|
||
|
|
progressDetailRow
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private var ringColor: Color {
|
||
|
|
switch engine.job.status {
|
||
|
|
case .completed: return AppTheme.green
|
||
|
|
case .failed: return AppTheme.red
|
||
|
|
default: return AppTheme.blue
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private var percentText: String {
|
||
|
|
"\(Int(engine.job.progress * 100))%"
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: Progress detail
|
||
|
|
|
||
|
|
private var progressDetailRow: some View {
|
||
|
|
VStack(spacing: 8) {
|
||
|
|
ProgressView(value: engine.job.progress)
|
||
|
|
.tint(AppTheme.blue)
|
||
|
|
|
||
|
|
HStack {
|
||
|
|
Text(engine.job.currentFileName)
|
||
|
|
.font(AppTheme.captionFont)
|
||
|
|
.foregroundColor(AppTheme.textSecondary)
|
||
|
|
.lineLimit(1)
|
||
|
|
.truncationMode(.middle)
|
||
|
|
Spacer()
|
||
|
|
Text(formatSpeed(engine.job.speedBytesPerSecond))
|
||
|
|
.font(AppTheme.captionFont)
|
||
|
|
.foregroundColor(AppTheme.textTertiary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: Completion
|
||
|
|
|
||
|
|
private var completionSummary: some View {
|
||
|
|
HStack(spacing: 24) {
|
||
|
|
statPill(count: engine.job.uploadedFiles, label: "uploaded", color: AppTheme.green)
|
||
|
|
statPill(count: engine.job.skippedFiles, label: "skipped", color: AppTheme.textSecondary)
|
||
|
|
statPill(count: engine.job.failedFiles, label: "errors", color: engine.job.failedFiles > 0 ? AppTheme.red : AppTheme.textTertiary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func statPill(count: Int, label: String, color: Color) -> some View {
|
||
|
|
VStack(spacing: 2) {
|
||
|
|
Text("\(count)")
|
||
|
|
.font(.system(size: 20, weight: .bold))
|
||
|
|
.foregroundColor(color)
|
||
|
|
Text(label)
|
||
|
|
.font(AppTheme.captionFont)
|
||
|
|
.foregroundColor(AppTheme.textSecondary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: NAS status row
|
||
|
|
|
||
|
|
private var nasStatusRow: some View {
|
||
|
|
HStack {
|
||
|
|
Image(systemName: "externaldrive.fill")
|
||
|
|
.foregroundColor(AppTheme.blue)
|
||
|
|
if let conn = store.savedConnection {
|
||
|
|
Text("\(conn.host) — \(conn.username)")
|
||
|
|
.font(AppTheme.bodyFont)
|
||
|
|
.foregroundColor(AppTheme.textPrimary)
|
||
|
|
}
|
||
|
|
Spacer()
|
||
|
|
HStack(spacing: 4) {
|
||
|
|
Circle().fill(AppTheme.green).frame(width: 8, height: 8)
|
||
|
|
Text("Live")
|
||
|
|
.font(AppTheme.captionFont)
|
||
|
|
.foregroundColor(AppTheme.green)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.padding(14)
|
||
|
|
.background(Color(hex: "#F9FAFB"))
|
||
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
|
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppTheme.cardBorder))
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: Options card
|
||
|
|
|
||
|
|
private var optionsCard: some View {
|
||
|
|
VStack(spacing: 0) {
|
||
|
|
optionRow(icon: "camera", title: "What to back up") {}
|
||
|
|
Divider().padding(.leading, 44)
|
||
|
|
optionRow(icon: "doc.badge.clock", title: "New files only") {}
|
||
|
|
Divider().padding(.leading, 44)
|
||
|
|
|
||
|
|
// Photos access row
|
||
|
|
Button(action: { Task { await vm.requestPhotosAccess() } }) {
|
||
|
|
HStack {
|
||
|
|
Image(systemName: "photo.on.rectangle")
|
||
|
|
.foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.blue : AppTheme.red)
|
||
|
|
.frame(width: 28)
|
||
|
|
Text("Photos access")
|
||
|
|
.font(AppTheme.bodyFont)
|
||
|
|
.foregroundColor(AppTheme.textPrimary)
|
||
|
|
Spacer()
|
||
|
|
Text(photosAccessLabel)
|
||
|
|
.font(AppTheme.captionFont)
|
||
|
|
.foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.green : AppTheme.red)
|
||
|
|
Image(systemName: "chevron.right")
|
||
|
|
.font(.system(size: 13))
|
||
|
|
.foregroundColor(AppTheme.textTertiary)
|
||
|
|
}
|
||
|
|
.padding(.horizontal, 16)
|
||
|
|
.padding(.vertical, 14)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.background(Color.white)
|
||
|
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
||
|
|
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder))
|
||
|
|
}
|
||
|
|
|
||
|
|
private var photosAccessLabel: String {
|
||
|
|
switch vm.photosAuthStatus {
|
||
|
|
case .authorized: return "Full access"
|
||
|
|
case .limited: return "Limited"
|
||
|
|
case .denied: return "Denied"
|
||
|
|
default: return "Not set"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func optionRow(icon: String, title: String, action: @escaping () -> Void) -> some View {
|
||
|
|
Button(action: action) {
|
||
|
|
HStack {
|
||
|
|
Image(systemName: icon)
|
||
|
|
.foregroundColor(AppTheme.blue)
|
||
|
|
.frame(width: 28)
|
||
|
|
Text(title)
|
||
|
|
.font(AppTheme.bodyFont)
|
||
|
|
.foregroundColor(AppTheme.textPrimary)
|
||
|
|
Spacer()
|
||
|
|
Image(systemName: "chevron.right")
|
||
|
|
.font(.system(size: 13))
|
||
|
|
.foregroundColor(AppTheme.textTertiary)
|
||
|
|
}
|
||
|
|
.padding(.horizontal, 16)
|
||
|
|
.padding(.vertical, 14)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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")
|
||
|
|
.font(.system(size: 17, weight: .semibold))
|
||
|
|
.foregroundColor(.white)
|
||
|
|
.frame(maxWidth: .infinity)
|
||
|
|
.frame(height: 52)
|
||
|
|
.background(engine.job.status == .completed ? AppTheme.green : AppTheme.blue)
|
||
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||
|
|
}
|
||
|
|
case .running:
|
||
|
|
Button(action: { engine.pause() }) {
|
||
|
|
Text("Pause")
|
||
|
|
.font(.system(size: 17, weight: .semibold))
|
||
|
|
.foregroundColor(.white)
|
||
|
|
.frame(maxWidth: .infinity)
|
||
|
|
.frame(height: 52)
|
||
|
|
.background(Color(hex: "#1E3A5F"))
|
||
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||
|
|
}
|
||
|
|
case .paused:
|
||
|
|
HStack(spacing: 12) {
|
||
|
|
Button(action: { engine.resume() }) {
|
||
|
|
Text("Resume")
|
||
|
|
.font(.system(size: 17, weight: .semibold))
|
||
|
|
.foregroundColor(.white)
|
||
|
|
.frame(maxWidth: .infinity)
|
||
|
|
.frame(height: 52)
|
||
|
|
.background(AppTheme.blue)
|
||
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||
|
|
}
|
||
|
|
Button(action: { engine.cancel() }) {
|
||
|
|
Text("Cancel")
|
||
|
|
.font(.system(size: 17, weight: .semibold))
|
||
|
|
.foregroundColor(AppTheme.red)
|
||
|
|
.frame(height: 52)
|
||
|
|
.frame(width: 90)
|
||
|
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(AppTheme.red))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
case .preparing:
|
||
|
|
HStack {
|
||
|
|
ProgressView()
|
||
|
|
Text("Preparing…")
|
||
|
|
.foregroundColor(AppTheme.textSecondary)
|
||
|
|
}
|
||
|
|
.frame(height: 52)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func startBackup() {
|
||
|
|
guard let conn = store.savedConnection else { return }
|
||
|
|
Task {
|
||
|
|
_ = try? await engine.run(connection: conn, filter: store.backupFilter)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func formatSpeed(_ bps: Double) -> String {
|
||
|
|
let mbps = bps / 1_000_000
|
||
|
|
return mbps >= 1 ? String(format: "%.1f MB/s", mbps) : String(format: "%.0f KB/s", bps / 1000)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func etaString(_ seconds: TimeInterval) -> String {
|
||
|
|
let m = Int(seconds) / 60
|
||
|
|
let s = Int(seconds) % 60
|
||
|
|
return m > 0 ? "\(m) min" : "\(s)s"
|
||
|
|
}
|
||
|
|
}
|