Files
Kisani/Features/Sync/SyncView.swift
Robin Kutesa 15f9da843c feat: Sync tab, hamburger menu, network status, browse UX
- 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>
2026-05-16 17:58:00 +03:00

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)
}
}