Add screenshot cleanup and NAS quarantine features

- 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>
This commit is contained in:
Robin Kutesa
2026-05-19 01:19:43 +03:00
parent 20b2b8eeb8
commit 089c9dfd67
4 changed files with 320 additions and 0 deletions

View File

@@ -1,4 +1,5 @@
import SwiftUI
import Photos
struct SyncView: View {
@EnvironmentObject var store: ConnectionStore
@@ -14,6 +15,11 @@ struct SyncView: View {
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()
@@ -55,6 +61,17 @@ struct SyncView: View {
.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)
@@ -198,6 +215,7 @@ struct SyncView: View {
.kerning(0.8)
.padding(.horizontal, 2)
// Auto-delete after backup
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: "iphone.and.arrow.forward")
@@ -244,9 +262,94 @@ struct SyncView: View {
.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"
@@ -398,3 +501,207 @@ struct QueueFileRow: View {
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
}
}
}