- Replace History tab with Sync tab (file list, NAS status, free-up-space retention) - Add AppMenuView hamburger sheet (History, Errors, Connection Status, Sync Activity) - BackupView: Kisani logo chip in toolbar, hamburger menu button, swipeable ring pages (progress/pending/failed/uploaded), always-visible compact stats strip - SettingsView: NETWORK section (SSID, NAS reachability, trusted-network warning, refresh button) - BrowseView: pull-to-refresh, long-press context menu on items - LANMonitor: nasReachable, nasCheckTime, checkNASReachability(host:port:) - ConnectionStore: localRetentionDays (0/60/90/365 days) - Regenerate xcodeproj with xcodegen to include new files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
395 lines
15 KiB
Swift
395 lines
15 KiB
Swift
import SwiftUI
|
|
|
|
struct SyncView: View {
|
|
@EnvironmentObject var store: ConnectionStore
|
|
@EnvironmentObject var lanMonitor: LANMonitor
|
|
|
|
@State private var files: [NASItem] = []
|
|
@State private var isLoading = false
|
|
@State private var error: String? = nil
|
|
@State private var appeared = false
|
|
|
|
private var connection: NASConnection? { store.savedConnection }
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
AppTheme.background.ignoresSafeArea()
|
|
|
|
if connection == nil {
|
|
noConnectionState
|
|
} else if isLoading && files.isEmpty {
|
|
loadingState
|
|
} else if let err = error {
|
|
errorState(err)
|
|
} else if files.isEmpty && !isLoading {
|
|
emptyState
|
|
} else {
|
|
fileList
|
|
}
|
|
}
|
|
.navigationTitle("Sync")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
nasStatusChip
|
|
}
|
|
}
|
|
.task { await loadFiles() }
|
|
.refreshable { await loadFiles() }
|
|
}
|
|
|
|
// MARK: — States
|
|
|
|
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 synced files")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var loadingState: some View {
|
|
VStack(spacing: 12) {
|
|
ProgressView().tint(AppTheme.inkTertiary)
|
|
Text("Loading files…")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
|
|
private func errorState(_ message: String) -> some View {
|
|
VStack(spacing: 20) {
|
|
ZStack {
|
|
Circle().fill(AppTheme.surfaceSunken).frame(width: 64, height: 64)
|
|
Image(systemName: "wifi.exclamationmark")
|
|
.font(.system(size: 24, weight: .light))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
}
|
|
VStack(spacing: 6) {
|
|
Text("Couldn't load files")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(AppTheme.ink)
|
|
Text(message)
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 40)
|
|
}
|
|
Button(action: { Task { await loadFiles() } }) {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: "arrow.clockwise").font(.system(size: 12, weight: .medium))
|
|
Text("Retry").font(.system(size: 13, weight: .medium))
|
|
}
|
|
.foregroundStyle(AppTheme.ink)
|
|
.padding(.horizontal, 16).padding(.vertical, 8)
|
|
.background(AppTheme.surfaceSunken)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
}
|
|
.buttonStyle(ScaleButtonStyle())
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: "checkmark.circle")
|
|
.font(.system(size: 32, weight: .ultraLight))
|
|
.foregroundStyle(AppTheme.inkQuaternary)
|
|
VStack(spacing: 5) {
|
|
Text("Nothing backed up yet")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
if let conn = connection {
|
|
Text(conn.remotePath)
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: — File list
|
|
|
|
private var fileList: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
// Header strip
|
|
headerStrip
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.vertical, 14)
|
|
|
|
Rectangle()
|
|
.fill(AppTheme.separator)
|
|
.frame(height: 0.5)
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
|
|
ForEach(Array(files.enumerated()), id: \.element.id) { idx, file in
|
|
SyncFileRow(item: file)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 6)
|
|
.animation(
|
|
.spring(response: 0.38, dampingFraction: 0.82)
|
|
.delay(Double(min(idx, 12)) * 0.03),
|
|
value: appeared
|
|
)
|
|
|
|
if idx < files.count - 1 {
|
|
Rectangle()
|
|
.fill(AppTheme.separator)
|
|
.frame(height: 0.5)
|
|
.padding(.leading, 60)
|
|
}
|
|
}
|
|
|
|
// Free Up Space section
|
|
freeUpSpaceSection
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.top, 28)
|
|
.padding(.bottom, 48)
|
|
}
|
|
}
|
|
.onAppear { appeared = true }
|
|
}
|
|
|
|
private var headerStrip: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("\(files.count) files backed up")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(AppTheme.ink)
|
|
if let conn = connection {
|
|
Text(conn.remotePath)
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
Spacer()
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkQuaternary)
|
|
.frame(width: 6, height: 6)
|
|
Text(lanMonitor.nasReachable == true ? "Available" : "Offline")
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: — Free Up Space
|
|
|
|
private var freeUpSpaceSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sectionLabel("FREE UP SPACE")
|
|
|
|
VStack(spacing: 0) {
|
|
// Explanation
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "iphone.and.arrow.forward")
|
|
.font(.system(size: 14, weight: .regular))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
.frame(width: 20)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Delete local copies after backup")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Text("Photos and videos already on your NAS")
|
|
.font(AppTheme.caption())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, AppTheme.cardPad)
|
|
.padding(.vertical, 12)
|
|
|
|
Rectangle()
|
|
.fill(AppTheme.separator)
|
|
.frame(height: 0.5)
|
|
.padding(.leading, AppTheme.cardPad)
|
|
|
|
// Retention picker
|
|
HStack {
|
|
Text("Delete after")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.ink)
|
|
Spacer()
|
|
Menu {
|
|
Button("Never (keep all)") { store.localRetentionDays = 0 }
|
|
Button("After 60 days") { store.localRetentionDays = 60 }
|
|
Button("After 90 days") { store.localRetentionDays = 90 }
|
|
Button("After 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(.horizontal, AppTheme.cardPad)
|
|
.padding(.vertical, 12)
|
|
|
|
if store.localRetentionDays > 0 {
|
|
Rectangle()
|
|
.fill(AppTheme.separator)
|
|
.frame(height: 0.5)
|
|
.padding(.leading, AppTheme.cardPad)
|
|
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "info.circle")
|
|
.font(.system(size: 12, weight: .regular))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
Text("Files will be removed from this device \(retentionLabel.lowercased()) they were successfully backed up. They remain on your NAS.")
|
|
.font(AppTheme.micro())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
.padding(.horizontal, AppTheme.cardPad)
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|
|
.background(AppTheme.surfaceRaised)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.localRetentionDays > 0)
|
|
}
|
|
}
|
|
|
|
private var retentionLabel: String {
|
|
switch store.localRetentionDays {
|
|
case 0: return "Never"
|
|
case 60: return "60 days"
|
|
case 90: return "90 days"
|
|
case 365: return "1 year"
|
|
default: return "\(store.localRetentionDays) days"
|
|
}
|
|
}
|
|
|
|
// MARK: — Toolbar chip
|
|
|
|
private var nasStatusChip: some View {
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkQuaternary)
|
|
.frame(width: 5, height: 5)
|
|
Text(connection?.displayName ?? "NAS")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(AppTheme.surfaceSunken)
|
|
.clipShape(Capsule(style: .continuous))
|
|
}
|
|
|
|
// MARK: — Data
|
|
|
|
private func loadFiles() async {
|
|
guard let conn = connection else { return }
|
|
isLoading = true
|
|
error = nil
|
|
do {
|
|
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
|
try await s.connect(to: conn.host, port: conn.port,
|
|
username: conn.username, password: conn.password)
|
|
files = try await s.listDirectory(at: conn.remotePath)
|
|
.filter { !$0.isDirectory }
|
|
.sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) }
|
|
s.disconnect()
|
|
} catch let e as BackupError {
|
|
self.error = e.errorDescription
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
// MARK: — File Row
|
|
|
|
struct SyncFileRow: View {
|
|
let item: NASItem
|
|
|
|
private var ext: String { (item.name as NSString).pathExtension.lowercased() }
|
|
|
|
private var fileIcon: String {
|
|
switch ext {
|
|
case "jpg", "jpeg", "png", "heic", "heif", "gif", "webp": return "photo"
|
|
case "mp4", "mov", "m4v", "avi": return "play.rectangle"
|
|
case "raw", "dng", "cr2", "nef", "arw": return "camera.aperture"
|
|
case "pdf": return "doc.richtext"
|
|
default: return "doc"
|
|
}
|
|
}
|
|
|
|
private var iconColor: Color {
|
|
switch ext {
|
|
case "jpg", "jpeg", "png", "heic", "heif", "gif", "webp": return AppTheme.interactive
|
|
case "mp4", "mov", "m4v", "avi": return AppTheme.destructive
|
|
case "raw", "dng", "cr2", "nef", "arw": return AppTheme.inkSecondary
|
|
default: return AppTheme.inkTertiary
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// File type icon
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(iconColor.opacity(0.1))
|
|
.frame(width: 38, height: 38)
|
|
Image(systemName: fileIcon)
|
|
.font(.system(size: 15, weight: .regular))
|
|
.foregroundStyle(iconColor)
|
|
}
|
|
|
|
// Name + meta
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(item.name)
|
|
.font(.system(size: 14, weight: .regular))
|
|
.foregroundStyle(AppTheme.ink)
|
|
.lineLimit(1)
|
|
|
|
HStack(spacing: 6) {
|
|
if let date = item.modifiedDate {
|
|
Text(date, format: .dateTime.month(.abbreviated).day().year())
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
if item.size > 0 {
|
|
Text("·").foregroundStyle(AppTheme.inkQuaternary)
|
|
Text(formatBytes(item.size))
|
|
.foregroundStyle(AppTheme.inkTertiary)
|
|
}
|
|
}
|
|
.font(.system(size: 11, weight: .regular))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Synced badge
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(AppTheme.positive.opacity(0.7))
|
|
}
|
|
.padding(.horizontal, AppTheme.hPad)
|
|
.padding(.vertical, 11)
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let gb = Double(bytes) / 1_073_741_824
|
|
if gb >= 1 { return String(format: "%.1f GB", gb) }
|
|
let mb = Double(bytes) / 1_048_576
|
|
if mb >= 1 { return String(format: "%.0f MB", mb) }
|
|
return String(format: "%.0f KB", Double(bytes) / 1024)
|
|
}
|
|
}
|