- NASTransferProtocol: add deleteFile(at:) to enable server-side deletion
- SFTPService: implement deleteFile using Citadel sftp.remove(at:)
- SMBService: implement deleteFile using SMBClient deleteFile(path:)
- SyncView FREE UP SPACE: two new cards —
• "Clean up screenshots" — shows count of phone screenshots already
backed up to NAS, opens review sheet for bulk PHPhotoLibrary deletion
• "NAS screenshot quarantine" — identifies NAS files that match phone
screenshots, offers to move them into a Screenshots/ subfolder via
download → writeData → deleteFile per file
Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
708 lines
28 KiB
Swift
708 lines
28 KiB
Swift
import SwiftUI
|
|
import Photos
|
|
|
|
struct SyncView: View {
|
|
@EnvironmentObject var store: ConnectionStore
|
|
@EnvironmentObject var lanMonitor: LANMonitor
|
|
@EnvironmentObject var engine: BackupEngine
|
|
@EnvironmentObject var statusService: BackupStatusService
|
|
|
|
private var connection: NASConnection? { store.savedConnection }
|
|
private var queueItems: [BackupQueueItem] { engine.queueItems }
|
|
|
|
private var uploadingItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploading } }
|
|
private var queuedItems: [BackupQueueItem] { queueItems.filter { $0.status == .queued } }
|
|
private var completedItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploaded || $0.status == .skipped } }
|
|
private var failedItems: [BackupQueueItem] { queueItems.filter { $0.status == .failed } }
|
|
|
|
@State private var phoneScreenshots: [LocalPhotoIndex.Record] = []
|
|
@State private var nasScreenshotNames: [String] = []
|
|
@State private var showPhoneCleanup = false
|
|
@State private var showNASQuarantine = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
AppTheme.background.ignoresSafeArea()
|
|
if connection == nil {
|
|
noConnectionState
|
|
} else {
|
|
contentList
|
|
}
|
|
}
|
|
.navigationTitle("Sync")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
// MARK: — Main content
|
|
|
|
private var contentList: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0, pinnedViews: []) {
|
|
nasHeader
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.vertical, 14)
|
|
|
|
Divider()
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
|
|
if queueItems.isEmpty {
|
|
idleState
|
|
.padding(.top, 48)
|
|
} else {
|
|
queueContent
|
|
}
|
|
|
|
freeUpSpaceSection
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.top, 32)
|
|
.padding(.bottom, 48)
|
|
}
|
|
}
|
|
.refreshable {
|
|
// Pull-to-refresh: show latest queue from engine (already live)
|
|
}
|
|
.task { await computeScreenshots() }
|
|
.sheet(isPresented: $showPhoneCleanup, onDismiss: { Task { await computeScreenshots() } }) {
|
|
if let conn = connection {
|
|
PhoneScreenshotCleanupSheet(records: phoneScreenshots, connection: conn)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showNASQuarantine, onDismiss: { Task { await computeScreenshots() } }) {
|
|
if let conn = connection {
|
|
NASQuarantineSheet(filenames: nasScreenshotNames, connection: conn)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: — NAS header (no pill, no border)
|
|
|
|
private var nasHeader: some View {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
if let conn = connection {
|
|
Text(conn.host)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(AppTheme.ink)
|
|
Text(conn.remotePath)
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.lineLimit(1)
|
|
Text(conn.nasProtocol == .smb ? "SMB" : "SFTP")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
}
|
|
}
|
|
Spacer()
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(nasColor)
|
|
.frame(width: 6, height: 6)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable)
|
|
Text(nasLabel)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(nasColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var nasColor: Color {
|
|
switch lanMonitor.nasReachable {
|
|
case true: AppTheme.positive
|
|
case false: AppTheme.destructive.opacity(0.8)
|
|
case nil: AppTheme.inkTertiary
|
|
}
|
|
}
|
|
|
|
private var nasLabel: String {
|
|
switch lanMonitor.nasReachable {
|
|
case true: "Online"
|
|
case false: "Offline"
|
|
case nil: "Checking…"
|
|
}
|
|
}
|
|
|
|
// MARK: — Queue content
|
|
|
|
@ViewBuilder
|
|
private var queueContent: some View {
|
|
// Active / uploading
|
|
if !uploadingItems.isEmpty || !queuedItems.isEmpty {
|
|
sectionHeader("ACTIVE", count: uploadingItems.count + queuedItems.count)
|
|
ForEach(uploadingItems + queuedItems) { item in
|
|
QueueFileRow(item: item)
|
|
Divider().padding(.leading, 58)
|
|
}
|
|
}
|
|
|
|
// Failed
|
|
if !failedItems.isEmpty {
|
|
sectionHeader("FAILED", count: failedItems.count)
|
|
ForEach(failedItems) { item in
|
|
QueueFileRow(item: item)
|
|
Divider().padding(.leading, 58)
|
|
}
|
|
}
|
|
|
|
// Completed
|
|
if !completedItems.isEmpty {
|
|
sectionHeader("COMPLETED", count: completedItems.count)
|
|
ForEach(completedItems.prefix(30)) { item in
|
|
QueueFileRow(item: item)
|
|
Divider().padding(.leading, 58)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sectionHeader(_ title: String, count: Int) -> some View {
|
|
HStack {
|
|
Text(title)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.kerning(0.8)
|
|
Spacer()
|
|
Text("\(count)")
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
}
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 6)
|
|
}
|
|
|
|
// MARK: — States
|
|
|
|
private var idleState: some View {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: statusService.snapshot.alreadySafe > 0 ? "checkmark.circle" : "arrow.up.doc")
|
|
.font(.system(size: 32, weight: .ultraLight))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
VStack(spacing: 5) {
|
|
Text(statusService.snapshot.alreadySafe > 0 ? "All caught up" : "No backup run yet")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
if statusService.snapshot.nasArchiveTotal > 0 {
|
|
Text("\(statusService.snapshot.nasArchiveTotal) files on NAS")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var noConnectionState: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "externaldrive.badge.xmark")
|
|
.font(.system(size: 36, weight: .ultraLight))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
VStack(spacing: 6) {
|
|
Text("No NAS connected")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
Text("Connect to a NAS to see sync activity")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: — Free Up Space
|
|
|
|
private var freeUpSpaceSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("FREE UP SPACE")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.kerning(0.8)
|
|
.padding(.horizontal, 2)
|
|
|
|
// Auto-delete after backup
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "iphone.and.arrow.forward")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.frame(width: 20)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Delete local copies after backup")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Text("Files already on your NAS")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(AppTheme.cardPad)
|
|
|
|
Divider().padding(.leading, AppTheme.cardPad)
|
|
|
|
HStack {
|
|
Text("Delete after")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Spacer()
|
|
Menu {
|
|
Button("Never") { store.localRetentionDays = 0 }
|
|
Button("60 days") { store.localRetentionDays = 60 }
|
|
Button("90 days") { store.localRetentionDays = 90 }
|
|
Button("1 year") { store.localRetentionDays = 365 }
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(retentionLabel)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
}
|
|
}
|
|
}
|
|
.padding(AppTheme.cardPad)
|
|
}
|
|
.background(AppTheme.surfaceRaised)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
|
|
|
// Phone screenshot cleanup
|
|
Button { showPhoneCleanup = true } label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "camera.viewfinder")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.frame(width: 20)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Clean up screenshots")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Text(phoneScreenshots.isEmpty
|
|
? "No backed-up screenshots found"
|
|
: "\(phoneScreenshots.count) screenshots safe to remove from phone")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
Spacer()
|
|
if !phoneScreenshots.isEmpty {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
}
|
|
}
|
|
.padding(AppTheme.cardPad)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(phoneScreenshots.isEmpty)
|
|
.background(AppTheme.surfaceRaised)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
|
|
|
// NAS screenshot quarantine
|
|
Button { showNASQuarantine = true } label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "folder.badge.minus")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.frame(width: 20)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("NAS screenshot quarantine")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Text(nasScreenshotNames.isEmpty
|
|
? "No screenshots found on NAS"
|
|
: "\(nasScreenshotNames.count) screenshots on NAS")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
Spacer()
|
|
if !nasScreenshotNames.isEmpty {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
}
|
|
}
|
|
.padding(AppTheme.cardPad)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(nasScreenshotNames.isEmpty)
|
|
.background(AppTheme.surfaceRaised)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
|
}
|
|
}
|
|
|
|
private func computeScreenshots() async {
|
|
await LocalPhotoIndex.shared.loadOrBuild()
|
|
let allRecords = await LocalPhotoIndex.shared.allRecords()
|
|
let screenshotRecords = allRecords.filter { $0.isScreenshot }
|
|
|
|
let nasFilenames: [String]
|
|
if let cached = await NASManifestCache.shared.directoryFilenames {
|
|
nasFilenames = cached
|
|
} else if let manifest = await NASManifestCache.shared.manifest {
|
|
nasFilenames = manifest.entries.map { $0.filename }
|
|
} else {
|
|
nasFilenames = []
|
|
}
|
|
let nasLower = Set(nasFilenames.map { $0.lowercased() })
|
|
|
|
phoneScreenshots = screenshotRecords.filter { nasLower.contains($0.filename.lowercased()) }
|
|
|
|
let screenshotLower = Set(screenshotRecords.map { $0.filename.lowercased() })
|
|
nasScreenshotNames = nasFilenames.filter { screenshotLower.contains($0.lowercased()) }
|
|
}
|
|
|
|
private var retentionLabel: String {
|
|
switch store.localRetentionDays {
|
|
case 0: "Never"
|
|
case 60: "60 days"
|
|
case 90: "90 days"
|
|
case 365: "1 year"
|
|
default: "\(store.localRetentionDays) days"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: — Per-file row
|
|
|
|
struct QueueFileRow: View {
|
|
let item: BackupQueueItem
|
|
|
|
private var ext: String { (item.filename as NSString).pathExtension.lowercased() }
|
|
|
|
private var fileIcon: String {
|
|
switch ext {
|
|
case "jpg","jpeg","png","heic","heif","gif","webp": "photo"
|
|
case "mp4","mov","m4v","avi": "play.rectangle"
|
|
case "raw","dng","cr2","nef","arw": "camera.aperture"
|
|
case "pdf": "doc.richtext"
|
|
default: "doc"
|
|
}
|
|
}
|
|
|
|
private var iconColor: Color {
|
|
switch ext {
|
|
case "jpg","jpeg","png","heic","heif","gif","webp": AppTheme.interactive
|
|
case "mp4","mov","m4v","avi": AppTheme.destructive
|
|
case "raw","dng","cr2","nef","arw": AppTheme.inkSecondary
|
|
default: AppTheme.inkTertiary
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(iconColor.opacity(0.1))
|
|
.frame(width: 38, height: 38)
|
|
Image(systemName: fileIcon)
|
|
.font(.system(size: 14, weight: .regular))
|
|
.foregroundStyle(iconColor)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(item.filename)
|
|
.font(.system(size: 13, weight: .regular))
|
|
.foregroundStyle(AppTheme.ink)
|
|
.lineLimit(1)
|
|
statusRow
|
|
}
|
|
|
|
Spacer()
|
|
|
|
statusBadge
|
|
}
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.vertical, 10)
|
|
|
|
// Progress bar for uploading
|
|
if item.status == .uploading && item.totalBytes > 0 {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Rectangle()
|
|
.fill(AppTheme.surfaceSunken)
|
|
.frame(height: 2)
|
|
Rectangle()
|
|
.fill(AppTheme.interactive)
|
|
.frame(width: geo.size.width * CGFloat(item.progress), height: 2)
|
|
.animation(.linear(duration: 0.1), value: item.progress)
|
|
}
|
|
}
|
|
.frame(height: 2)
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusRow: some View {
|
|
switch item.status {
|
|
case .uploading:
|
|
if item.totalBytes > 0 {
|
|
HStack(spacing: 4) {
|
|
Text("\(formatBytes(item.bytesUploaded)) / \(formatBytes(item.totalBytes))")
|
|
if item.speedBytesPerSec > 0 {
|
|
Text("·")
|
|
Text("\(formatBytes(Int64(item.speedBytesPerSec)))/s")
|
|
}
|
|
}
|
|
.font(.system(size: 11, weight: .regular))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
} else {
|
|
Text("Uploading…")
|
|
.font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
case .queued:
|
|
Text("Queued")
|
|
.font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary)
|
|
case .uploaded, .skipped:
|
|
if let date = item.completedAt {
|
|
Text(date, format: .dateTime.month(.abbreviated).day().hour().minute())
|
|
.font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
case .failed:
|
|
Text(item.error?.message ?? "Upload failed")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(AppTheme.destructive.opacity(0.85))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusBadge: some View {
|
|
switch item.status {
|
|
case .uploading:
|
|
ProgressView()
|
|
.scaleEffect(0.7)
|
|
.tint(AppTheme.interactive)
|
|
case .queued:
|
|
Image(systemName: "clock")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
case .uploaded:
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(AppTheme.positive.opacity(0.8))
|
|
case .skipped:
|
|
Image(systemName: "arrow.forward.circle")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
case .failed:
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(AppTheme.destructive.opacity(0.8))
|
|
}
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let mb = Double(bytes) / 1_048_576
|
|
if mb < 1 { return "\(max(0, bytes / 1024)) KB" }
|
|
if mb < 1000 { return String(format: "%.1f MB", mb) }
|
|
return String(format: "%.1f GB", mb / 1024)
|
|
}
|
|
}
|
|
|
|
// MARK: — Phone Screenshot Cleanup Sheet
|
|
|
|
struct PhoneScreenshotCleanupSheet: View {
|
|
let records: [LocalPhotoIndex.Record]
|
|
let connection: NASConnection
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var isDeleting = false
|
|
@State private var done = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
ForEach(records, id: \.localIdentifier) { record in
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "camera.viewfinder")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.frame(width: 22)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(record.filename)
|
|
.font(.system(size: 13))
|
|
.lineLimit(1)
|
|
if let date = record.creationDate {
|
|
Text(date, style: .date)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("\(records.count) screenshots already backed up to NAS")
|
|
}
|
|
}
|
|
.navigationTitle("Phone Screenshots")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
}
|
|
.safeAreaInset(edge: .bottom) {
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
Button {
|
|
Task { await deleteAll() }
|
|
} label: {
|
|
Group {
|
|
if isDeleting {
|
|
ProgressView().tint(.white)
|
|
} else if done {
|
|
Label("Deleted", systemImage: "checkmark")
|
|
} else {
|
|
Text("Delete \(records.count) Screenshots from Phone")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(AppTheme.destructive)
|
|
.disabled(isDeleting || done)
|
|
.padding()
|
|
}
|
|
.background(AppTheme.background)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func deleteAll() async {
|
|
isDeleting = true
|
|
let ids = records.map { $0.localIdentifier }
|
|
let assets = PHAsset.fetchAssets(withLocalIdentifiers: ids, options: nil)
|
|
try? await PHPhotoLibrary.shared().performChanges {
|
|
PHAssetChangeRequest.deleteAssets(assets)
|
|
}
|
|
isDeleting = false
|
|
done = true
|
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
// MARK: — NAS Quarantine Sheet
|
|
|
|
struct NASQuarantineSheet: View {
|
|
let filenames: [String]
|
|
let connection: NASConnection
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var progress: (done: Int, total: Int) = (0, 0)
|
|
@State private var isMoving = false
|
|
@State private var done = false
|
|
@State private var errorMessage: String?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
Image(systemName: "folder.badge.plus")
|
|
.font(.system(size: 52, weight: .ultraLight))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
.padding(.top, 40)
|
|
|
|
VStack(spacing: 6) {
|
|
Text("\(filenames.count) screenshots on NAS")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(AppTheme.ink)
|
|
Text("Move to a Screenshots/ subfolder in your NAS backup directory.")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
Text(connection.remotePath + "/Screenshots/")
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
if isMoving {
|
|
VStack(spacing: 8) {
|
|
ProgressView(value: Double(progress.done),
|
|
total: Double(max(progress.total, 1)))
|
|
.padding(.horizontal, 40)
|
|
Text("\(progress.done) of \(progress.total) moved")
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
|
|
if done {
|
|
Label("All screenshots moved", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(AppTheme.positive)
|
|
.font(.system(size: 14, weight: .medium))
|
|
}
|
|
|
|
if let err = errorMessage {
|
|
Text(err)
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.destructive)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.navigationTitle("NAS Quarantine")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
}
|
|
.safeAreaInset(edge: .bottom) {
|
|
if !done && !isMoving {
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
Button {
|
|
Task { await quarantine() }
|
|
} label: {
|
|
Text("Move \(filenames.count) Screenshots")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.padding()
|
|
}
|
|
.background(AppTheme.background)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func quarantine() async {
|
|
isMoving = true
|
|
errorMessage = nil
|
|
progress = (0, filenames.count)
|
|
let conn = connection
|
|
let destDir = "\(conn.remotePath)/Screenshots"
|
|
do {
|
|
let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
|
try await transfer.connect(to: conn.host, port: conn.port,
|
|
username: conn.username, password: conn.password)
|
|
if !(try await transfer.fileExists(at: destDir)) {
|
|
try await transfer.createDirectory(at: destDir)
|
|
}
|
|
for (i, filename) in filenames.enumerated() {
|
|
let src = "\(conn.remotePath)/\(filename)"
|
|
let dst = "\(destDir)/\(filename)"
|
|
if let data = try? await transfer.downloadData(at: src) {
|
|
try? await transfer.writeData(data, to: dst)
|
|
try? await transfer.deleteFile(at: src)
|
|
}
|
|
progress = (i + 1, filenames.count)
|
|
}
|
|
transfer.disconnect()
|
|
done = true
|
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
dismiss()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
isMoving = false
|
|
}
|
|
}
|
|
}
|