211 lines
7.6 KiB
Swift
211 lines
7.6 KiB
Swift
import SwiftUI
|
|
import ConfettiSwiftUI
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// BackupRestoreView.swift
|
|
// Settings section: Export Backup (share sheet), Import Backup (file picker),
|
|
// pending operations count, and confetti on successful import.
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
struct BackupRestoreSection: View {
|
|
@ObservedObject private var pendingOps = PendingOperationsQueue.shared
|
|
@State private var showExportSheet = false
|
|
@State private var showImportPicker = false
|
|
@State private var showImportSuccess = false
|
|
@State private var showError = false
|
|
@State private var errorMessage = ""
|
|
@State private var importedManifest: BackupManifest?
|
|
@State private var exportURL: URL?
|
|
@State private var confettiTrigger = 0
|
|
@State private var isExporting = false
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
Section("Backup & Restore") {
|
|
// Export
|
|
Button {
|
|
exportBackup()
|
|
} label: {
|
|
HStack {
|
|
Label("Export Backup", systemImage: "square.and.arrow.up")
|
|
Spacer()
|
|
if isExporting {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
}
|
|
}
|
|
}
|
|
.disabled(isExporting)
|
|
|
|
// Import
|
|
Button {
|
|
showImportPicker = true
|
|
} label: {
|
|
Label("Import Backup", systemImage: "square.and.arrow.down")
|
|
}
|
|
|
|
// Pending operations
|
|
if !pendingOps.isEmpty {
|
|
NavigationLink {
|
|
PendingOperationsListView()
|
|
} label: {
|
|
HStack {
|
|
Label("Pending Operations", systemImage: "clock.arrow.circlepath")
|
|
Spacer()
|
|
Text("\(pendingOps.count)")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
.background(accentPink.cornerRadius(10))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showExportSheet) {
|
|
if let url = exportURL {
|
|
ShareSheet(items: [url])
|
|
}
|
|
}
|
|
.fileImporter(
|
|
isPresented: $showImportPicker,
|
|
allowedContentTypes: [.init(filenameExtension: "nvdbackup")!],
|
|
allowsMultipleSelection: false
|
|
) { result in
|
|
handleImport(result)
|
|
}
|
|
.alert("Import Successful", isPresented: $showImportSuccess) {
|
|
Button("OK") {
|
|
confettiTrigger += 1
|
|
}
|
|
} message: {
|
|
if let m = importedManifest {
|
|
Text("Restored from \(m.deviceName) (\(m.deviceModel))\n\(formattedDate(m.exportDate))\n\nPlease re-enter your server password.")
|
|
}
|
|
}
|
|
.alert("Backup Error", isPresented: $showError) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(errorMessage)
|
|
}
|
|
.overlay {
|
|
// ConfettiCannon as overlay prevents it from interfering with List row layout
|
|
EmptyView().confettiCannon(trigger: $confettiTrigger, num: 50, radius: 400)
|
|
}
|
|
}
|
|
|
|
private func exportBackup() {
|
|
isExporting = true
|
|
Task {
|
|
do {
|
|
let url = try BackupManager.shared.exportBackup()
|
|
await MainActor.run {
|
|
exportURL = url
|
|
isExporting = false
|
|
showExportSheet = true
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isExporting = false
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleImport(_ result: Result<[URL], Error>) {
|
|
switch result {
|
|
case .success(let urls):
|
|
guard let url = urls.first else { return }
|
|
do {
|
|
let manifest = try BackupManager.shared.importBackup(from: url)
|
|
importedManifest = manifest
|
|
showImportSuccess = true
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
case .failure(let error):
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
}
|
|
|
|
private func formattedDate(_ date: Date) -> String {
|
|
let df = DateFormatter()
|
|
df.dateStyle = .medium
|
|
df.timeStyle = .short
|
|
return df.string(from: date)
|
|
}
|
|
}
|
|
|
|
// MARK: - Share Sheet (UIKit wrapper)
|
|
|
|
struct ShareSheet: UIViewControllerRepresentable {
|
|
let items: [Any]
|
|
|
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
|
}
|
|
|
|
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
|
|
}
|
|
|
|
// MARK: - Pending Operations List
|
|
|
|
struct PendingOperationsListView: View {
|
|
@ObservedObject private var queue = PendingOperationsQueue.shared
|
|
|
|
var body: some View {
|
|
List {
|
|
if queue.isEmpty {
|
|
Text("No pending operations")
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(queue.operations) { op in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(op.displayDescription)
|
|
.font(.system(size: 14, weight: .medium))
|
|
|
|
HStack {
|
|
Text(op.type.rawValue)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Text("Retry \(op.retryCount)/\(op.maxRetries)")
|
|
.font(.system(size: 11, weight: .medium).monospacedDigit())
|
|
.foregroundStyle(op.retryCount >= 3 ? .red : .secondary)
|
|
}
|
|
|
|
Text(op.createdAt, style: .relative)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
.onDelete { offsets in
|
|
for offset in offsets {
|
|
queue.remove(queue.operations[offset])
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button("Retry All Now") {
|
|
queue.processAll()
|
|
}
|
|
.disabled(queue.isEmpty)
|
|
|
|
Button("Clear All", role: .destructive) {
|
|
queue.clearAll()
|
|
}
|
|
.disabled(queue.isEmpty)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Pending Operations")
|
|
}
|
|
}
|