Light mode: white disk icon on black bg Dark mode: black disk icon on white bg No green — uses AppTheme.ink + AppTheme.inkInverse throughout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
431 lines
17 KiB
Swift
431 lines
17 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 ringPage = 0
|
|
|
|
private let ringPageCount = 4
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
AppTheme.background.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
Spacer(minLength: 0)
|
|
|
|
// ─── Ring hero ───────────────────────────────────────
|
|
ringHero
|
|
.padding(.bottom, 24)
|
|
|
|
// ─── Compact stats strip (always visible) ─────────────
|
|
statsStrip
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.bottom, 12)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.97)))
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
// ─── NAS status ───────────────────────────────────────
|
|
nasStatusRow
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.bottom, 12)
|
|
|
|
// ─── Photos access nudge ──────────────────────────────
|
|
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
|
|
photosAccessRow
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.bottom, 12)
|
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
|
}
|
|
|
|
// ─── Action ───────────────────────────────────────────
|
|
actionButton
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.bottom, 32)
|
|
}
|
|
}
|
|
.navigationTitle("")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
logoChip
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button {
|
|
showMenu = true
|
|
} label: {
|
|
Image(systemName: "line.3.horizontal")
|
|
.font(.system(size: 16, weight: .regular))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
}
|
|
.accessibilityLabel("Activity menu")
|
|
}
|
|
}
|
|
.sheet(isPresented: $showMenu) {
|
|
AppMenuView()
|
|
.environmentObject(store)
|
|
.environmentObject(lanMonitor)
|
|
}
|
|
.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) }
|
|
}
|
|
|
|
// MARK: — Logo chip
|
|
|
|
private var logoChip: some View {
|
|
HStack(spacing: 6) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(AppTheme.ink)
|
|
.frame(width: 22, height: 22)
|
|
Image(systemName: "internaldrive.fill")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkInverse)
|
|
}
|
|
Text("kisani.")
|
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.kerning(0.5)
|
|
}
|
|
}
|
|
|
|
// MARK: — Ring hero
|
|
|
|
private var ringHero: some View {
|
|
ZStack {
|
|
ProgressRing(
|
|
progress: engine.job.progress,
|
|
lineWidth: 7,
|
|
size: 220,
|
|
color: ringColor,
|
|
backgroundColor: AppTheme.inkQuaternary.opacity(0.4)
|
|
)
|
|
.accessibilityLabel("Backup progress")
|
|
.accessibilityValue("\(Int(engine.job.progress * 100)) percent")
|
|
|
|
VStack(spacing: 4) {
|
|
ringCenterContent
|
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
|
.id(ringPage)
|
|
}
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringPage)
|
|
}
|
|
.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)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var ringCenterContent: some View {
|
|
switch ringPage {
|
|
case 0: // Progress / status
|
|
VStack(spacing: 4) {
|
|
Text(percentText)
|
|
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(AppTheme.ink)
|
|
.contentTransition(.numericText())
|
|
.monospacedDigit()
|
|
ringStatusLabel
|
|
}
|
|
|
|
case 1: // Pending
|
|
VStack(spacing: 4) {
|
|
Text("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))")
|
|
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(AppTheme.ink)
|
|
.contentTransition(.numericText())
|
|
.monospacedDigit()
|
|
Text("pending")
|
|
.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: // Completed
|
|
VStack(spacing: 4) {
|
|
Text("\(engine.job.uploadedFiles)")
|
|
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(AppTheme.positive)
|
|
.contentTransition(.numericText())
|
|
.monospacedDigit()
|
|
Text("uploaded")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var ringStatusLabel: some View {
|
|
switch engine.job.status {
|
|
case .running:
|
|
VStack(spacing: 2) {
|
|
Text("\(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)
|
|
}
|
|
Text(engine.job.currentFileName)
|
|
.font(.system(size: 11, weight: .regular))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.frame(maxWidth: 160)
|
|
}
|
|
case .preparing:
|
|
Text("Preparing…")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
case .completed:
|
|
Text("Done")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(AppTheme.positive)
|
|
case .failed:
|
|
Text("Failed")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(AppTheme.destructive)
|
|
case .cancelled:
|
|
Text("Cancelled")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
default:
|
|
Text(vm.totalPhotoCount > 0 ? "\(vm.totalPhotoCount) photos ready" : "Ready")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
|
|
// MARK: — Page indicator dots
|
|
|
|
private var pageIndicator: some View {
|
|
HStack(spacing: 5) {
|
|
ForEach(0..<ringPageCount, id: \.self) { i in
|
|
Circle()
|
|
.fill(i == ringPage ? AppTheme.ink : AppTheme.inkQuaternary)
|
|
.frame(width: i == ringPage ? 5 : 4, height: i == ringPage ? 5 : 4)
|
|
.animation(.spring(response: 0.25, dampingFraction: 0.8), 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.inkQuaternary.opacity(0.6)
|
|
}
|
|
}
|
|
|
|
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
|
|
|
|
// MARK: — Compact stats strip
|
|
|
|
private var statsStrip: some View {
|
|
VStack(spacing: 5) {
|
|
HStack(spacing: 0) {
|
|
compactStat("\(engine.job.uploadedFiles)", label: "up", color: AppTheme.positive)
|
|
thinDivider
|
|
compactStat("\(engine.job.skippedFiles)", label: "skip", color: AppTheme.inkQuaternary)
|
|
thinDivider
|
|
compactStat("\(engine.job.failedFiles)", label: "err",
|
|
color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkQuaternary)
|
|
thinDivider
|
|
compactStat("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))", label: "left", color: AppTheme.inkSecondary)
|
|
}
|
|
.background(AppTheme.surfaceSunken)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
|
|
pageIndicator
|
|
}
|
|
}
|
|
|
|
private func compactStat(_ value: String, label: String, color: Color) -> some View {
|
|
HStack(spacing: 4) {
|
|
Text(value)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(color)
|
|
.monospacedDigit()
|
|
Text(label)
|
|
.font(.system(size: 10, weight: .regular))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private var thinDivider: some View {
|
|
Rectangle()
|
|
.fill(AppTheme.separator)
|
|
.frame(width: 0.5)
|
|
.padding(.vertical, 5)
|
|
}
|
|
|
|
// 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 {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(conn.displayName)
|
|
.font(AppTheme.headline())
|
|
.foregroundStyle(AppTheme.ink)
|
|
HStack(spacing: 4) {
|
|
Text(conn.nasProtocol.rawValue)
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
if let ssid = lanMonitor.currentSSID {
|
|
Text("·")
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
Text(ssid)
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
.font(AppTheme.micro(10))
|
|
}
|
|
} else {
|
|
Text("No NAS configured")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
|
|
.frame(width: 6, height: 6)
|
|
Text(engine.job.status.isActive ? "Active" : "Ready")
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary)
|
|
}
|
|
if let reachable = lanMonitor.nasReachable {
|
|
Text(reachable ? "NAS reachable" : "NAS offline")
|
|
.font(.system(size: 9, weight: .regular))
|
|
.foregroundStyle(reachable ? AppTheme.positive.opacity(0.7) : AppTheme.destructive.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"
|
|
}
|
|
}
|