Initial scaffold — NASBackup iOS app

Full project scaffold: XcodeGen project.yml, Core models/protocols/errors,
SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2),
PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager,
all feature UIs (Login, Connect, Browse, Backup, History, Settings),
Shared components and theme. Builds clean with zero errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-05-15 17:05:04 +03:00
commit b96711c535
44 changed files with 3412 additions and 0 deletions

View File

@@ -0,0 +1,301 @@
import SwiftUI
import Photos
struct BackupView: View {
@EnvironmentObject var engine: BackupEngine
@EnvironmentObject var store: ConnectionStore
@StateObject private var vm = BackupViewModel()
var body: some View {
ZStack {
Color.white.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Ring + counts
ringSection
// NAS status
nasStatusRow
// Options card
optionsCard
// Start / pause button
actionButton
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 40)
}
}
.task {
vm.loadPhotoCount(filter: store.backupFilter)
}
}
// MARK: Ring
private var ringSection: some View {
VStack(spacing: 16) {
ZStack {
ProgressRing(
progress: engine.job.progress,
lineWidth: 14,
size: 200,
color: ringColor
)
VStack(spacing: 4) {
Text(percentText)
.font(.system(size: 36, weight: .bold))
.foregroundColor(AppTheme.textPrimary)
if case .running = engine.job.status {
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) / \(engine.job.totalFiles)")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textSecondary)
if let eta = engine.job.eta {
Text("~\(etaString(eta))")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
}
} else {
Text("\(vm.totalPhotoCount) photos")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textSecondary)
Text("0 backed up")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
}
}
}
// Completion summary
if case .completed = engine.job.status {
completionSummary
}
// Progress detail row
if case .running = engine.job.status {
progressDetailRow
}
}
}
private var ringColor: Color {
switch engine.job.status {
case .completed: return AppTheme.green
case .failed: return AppTheme.red
default: return AppTheme.blue
}
}
private var percentText: String {
"\(Int(engine.job.progress * 100))%"
}
// MARK: Progress detail
private var progressDetailRow: some View {
VStack(spacing: 8) {
ProgressView(value: engine.job.progress)
.tint(AppTheme.blue)
HStack {
Text(engine.job.currentFileName)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(formatSpeed(engine.job.speedBytesPerSecond))
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
}
}
}
// MARK: Completion
private var completionSummary: some View {
HStack(spacing: 24) {
statPill(count: engine.job.uploadedFiles, label: "uploaded", color: AppTheme.green)
statPill(count: engine.job.skippedFiles, label: "skipped", color: AppTheme.textSecondary)
statPill(count: engine.job.failedFiles, label: "errors", color: engine.job.failedFiles > 0 ? AppTheme.red : AppTheme.textTertiary)
}
}
private func statPill(count: Int, label: String, color: Color) -> some View {
VStack(spacing: 2) {
Text("\(count)")
.font(.system(size: 20, weight: .bold))
.foregroundColor(color)
Text(label)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textSecondary)
}
}
// MARK: NAS status row
private var nasStatusRow: some View {
HStack {
Image(systemName: "externaldrive.fill")
.foregroundColor(AppTheme.blue)
if let conn = store.savedConnection {
Text("\(conn.host)\(conn.username)")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textPrimary)
}
Spacer()
HStack(spacing: 4) {
Circle().fill(AppTheme.green).frame(width: 8, height: 8)
Text("Live")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.green)
}
}
.padding(14)
.background(Color(hex: "#F9FAFB"))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppTheme.cardBorder))
}
// MARK: Options card
private var optionsCard: some View {
VStack(spacing: 0) {
optionRow(icon: "camera", title: "What to back up") {}
Divider().padding(.leading, 44)
optionRow(icon: "doc.badge.clock", title: "New files only") {}
Divider().padding(.leading, 44)
// Photos access row
Button(action: { Task { await vm.requestPhotosAccess() } }) {
HStack {
Image(systemName: "photo.on.rectangle")
.foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.blue : AppTheme.red)
.frame(width: 28)
Text("Photos access")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textPrimary)
Spacer()
Text(photosAccessLabel)
.font(AppTheme.captionFont)
.foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.green : AppTheme.red)
Image(systemName: "chevron.right")
.font(.system(size: 13))
.foregroundColor(AppTheme.textTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder))
}
private var photosAccessLabel: String {
switch vm.photosAuthStatus {
case .authorized: return "Full access"
case .limited: return "Limited"
case .denied: return "Denied"
default: return "Not set"
}
}
private func optionRow(icon: String, title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Image(systemName: icon)
.foregroundColor(AppTheme.blue)
.frame(width: 28)
Text(title)
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13))
.foregroundColor(AppTheme.textTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
// MARK: Action button
private var actionButton: some View {
Group {
switch engine.job.status {
case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) {
Text(engine.job.status == .completed ? "Back up again" : "Start backup")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(engine.job.status == .completed ? AppTheme.green : AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
case .running:
Button(action: { engine.pause() }) {
Text("Pause")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(Color(hex: "#1E3A5F"))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
case .paused:
HStack(spacing: 12) {
Button(action: { engine.resume() }) {
Text("Resume")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
Button(action: { engine.cancel() }) {
Text("Cancel")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(AppTheme.red)
.frame(height: 52)
.frame(width: 90)
.overlay(RoundedRectangle(cornerRadius: 14).stroke(AppTheme.red))
}
}
case .preparing:
HStack {
ProgressView()
Text("Preparing…")
.foregroundColor(AppTheme.textSecondary)
}
.frame(height: 52)
}
}
}
private func startBackup() {
guard let conn = store.savedConnection else { return }
Task {
_ = try? await engine.run(connection: conn, filter: store.backupFilter)
}
}
private func formatSpeed(_ bps: Double) -> String {
let mbps = bps / 1_000_000
return mbps >= 1 ? String(format: "%.1f MB/s", mbps) : String(format: "%.0f KB/s", bps / 1000)
}
private func etaString(_ seconds: TimeInterval) -> String {
let m = Int(seconds) / 60
let s = Int(seconds) % 60
return m > 0 ? "\(m) min" : "\(s)s"
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
import Photos
@MainActor
final class BackupViewModel: ObservableObject {
@Published var totalPhotoCount: Int = 0
@Published var photosAuthStatus: PHAuthorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
private let photoService = PhotoLibraryService()
func loadPhotoCount(filter: BackupFilter) {
let assets = photoService.fetchAssets(filter: filter)
totalPhotoCount = assets.count
}
func requestPhotosAccess() async {
photosAuthStatus = await photoService.requestAuthorization()
}
}

View File

@@ -0,0 +1,164 @@
import SwiftUI
struct BrowseView: View {
let connection: NASConnection
@Binding var selectedPath: String
@Environment(\.dismiss) private var dismiss
@State private var currentPath: String = "/"
@State private var items: [NASItem] = []
@State private var isLoading = false
@State private var error: String? = nil
@State private var showCreateFolder = false
@State private var newFolderName = ""
private var service: any NASTransferProtocol {
connection.nasProtocol == .smb ? SMBService() : SFTPService()
}
var body: some View {
NavigationStack {
ZStack {
Color.white.ignoresSafeArea()
VStack(spacing: 0) {
// Path bar
HStack {
Image(systemName: "externaldrive.fill")
.foregroundColor(AppTheme.textSecondary)
.font(.system(size: 13))
Text(currentPath)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(hex: "#F9FAFB"))
Divider()
if isLoading {
Spacer()
ProgressView()
Spacer()
} else if let error {
Spacer()
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 32))
.foregroundColor(AppTheme.textTertiary)
Text(error)
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textSecondary)
.multilineTextAlignment(.center)
}
.padding()
Spacer()
} else {
List {
if currentPath != "/" {
Button(action: navigateUp) {
HStack {
Image(systemName: "arrow.up")
.foregroundColor(AppTheme.textSecondary)
Text("Parent folder")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textSecondary)
}
}
}
ForEach(items) { item in
FolderRow(
item: item,
isSelected: selectedPath == item.path
) {
if item.isDirectory {
navigate(to: item.path)
} else {
selectedPath = item.path
}
}
.listRowInsets(EdgeInsets())
.listRowSeparator(.visible)
}
}
.listStyle(.plain)
}
}
}
.navigationTitle("Choose folder")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
newFolderName = ""
showCreateFolder = true
} label: {
Image(systemName: "folder.badge.plus")
}
}
ToolbarItem(placement: .bottomBar) {
Button("Select this folder") {
selectedPath = currentPath
dismiss()
}
.font(.system(size: 17, weight: .semibold))
.foregroundColor(AppTheme.blue)
}
}
.alert("New folder", isPresented: $showCreateFolder) {
TextField("Folder name", text: $newFolderName)
Button("Create") { Task { await createFolder() } }
Button("Cancel", role: .cancel) {}
}
}
.task { await loadDirectory(path: currentPath) }
}
private func loadDirectory(path: String) async {
isLoading = true
error = nil
do {
let s: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService()
try await s.connect(to: connection.host, port: connection.port,
username: connection.username, password: connection.password)
items = try await s.listDirectory(at: path).filter { $0.isDirectory }
s.disconnect()
} catch let e as BackupError {
self.error = e.errorDescription
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
private func navigate(to path: String) {
currentPath = path
Task { await loadDirectory(path: path) }
}
private func navigateUp() {
let parent = (currentPath as NSString).deletingLastPathComponent
navigate(to: parent.isEmpty ? "/" : parent)
}
private func createFolder() async {
guard !newFolderName.isEmpty else { return }
let newPath = "\(currentPath)/\(newFolderName)"
do {
let s: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService()
try await s.connect(to: connection.host, port: connection.port,
username: connection.username, password: connection.password)
try await s.createDirectory(at: newPath)
s.disconnect()
await loadDirectory(path: currentPath)
} catch {}
}
}

View File

@@ -0,0 +1,157 @@
import SwiftUI
struct ConnectView: View {
@StateObject private var vm = ConnectViewModel()
@State private var navigateToDashboard = false
var body: some View {
NavigationStack {
ZStack {
Color.white.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
// Logo
Image("nas_logo_clean")
.resizable()
.scaledToFit()
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.top, 48)
.padding(.bottom, 28)
// Protocol picker
Picker("Protocol", selection: $vm.selectedProtocol) {
ForEach(NASProtocol.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
.padding(.bottom, 20)
// Form card
VStack(spacing: 0) {
fieldRow(icon: "network", placeholder: "IP Address or hostname",
text: $vm.host, keyboardType: .URL)
Divider().padding(.leading, 52)
fieldRow(icon: "person", placeholder: "Username",
text: $vm.username, keyboardType: .emailAddress)
Divider().padding(.leading, 52)
secureFieldRow(icon: "lock", placeholder: "Password", text: $vm.password)
Divider().padding(.leading, 52)
// Folder picker row
Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) {
HStack(spacing: 12) {
Image(systemName: "folder")
.font(.system(size: 16))
.foregroundColor(vm.isConnected ? AppTheme.blue : AppTheme.textTertiary)
.frame(width: 28)
Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath)
.font(AppTheme.bodyFont)
.foregroundColor(vm.remotePath == "/" ? AppTheme.textTertiary : AppTheme.textPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13))
.foregroundColor(AppTheme.textTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
.stroke(vm.isConnected ? AppTheme.connectedBorder :
(vm.verifyError != nil ? AppTheme.red : AppTheme.cardBorder),
lineWidth: vm.isConnected ? 1.5 : 1)
)
.padding(.horizontal, 20)
if let error = vm.verifyError {
Text(error)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.red)
.padding(.top, 10)
.padding(.horizontal, 24)
}
if vm.isConnected {
Text("Connected successfully")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.green)
.padding(.top, 10)
}
// Connect button sits BELOW the card with explicit gap
Spacer().frame(height: 24)
Button(action: {
if vm.isConnected {
navigateToDashboard = true
} else {
Task { await vm.verify() }
}
}) {
Group {
if vm.isVerifying {
ProgressView().tint(.white)
} else {
Text(vm.isConnected ? "Connected — continue" : "Connect")
.font(.system(size: 17, weight: .semibold))
}
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(vm.isConnected ? AppTheme.green : AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!vm.canConnect)
.padding(.horizontal, 20)
.padding(.bottom, 40)
}
}
}
.navigationTitle("Connect to NAS")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(isPresented: $navigateToDashboard) {
MainTabView()
}
.sheet(isPresented: $vm.showFolderBrowser) {
if let conn = ConnectionStore.shared.savedConnection {
BrowseView(connection: conn, selectedPath: $vm.remotePath)
}
}
}
}
private func fieldRow(icon: String, placeholder: String, text: Binding<String>, keyboardType: UIKeyboardType = .default) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundColor(AppTheme.textSecondary)
.frame(width: 28)
TextField(placeholder, text: text)
.font(AppTheme.bodyFont)
.keyboardType(keyboardType)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
private func secureFieldRow(icon: String, placeholder: String, text: Binding<String>) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundColor(AppTheme.textSecondary)
.frame(width: 28)
SecureField(placeholder, text: text)
.font(AppTheme.bodyFont)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
enum ConnectField { case host, username, password, folder }
enum FieldState { case idle, valid, invalid }
@MainActor
final class ConnectViewModel: ObservableObject {
@Published var host = ""
@Published var username = ""
@Published var password = ""
@Published var remotePath = "/"
@Published var selectedProtocol: NASProtocol = .smb
@Published var isVerifying = false
@Published var isConnected = false
@Published var verifyError: String? = nil
@Published var showFolderBrowser = false
var fieldState: FieldState {
isConnected ? .valid : (verifyError != nil ? .invalid : .idle)
}
var canConnect: Bool {
!host.isEmpty && !username.isEmpty && !password.isEmpty && !isVerifying
}
func verify() async {
isVerifying = true
isConnected = false
verifyError = nil
let connection = NASConnection(
host: host,
nasProtocol: selectedProtocol,
username: username,
password: password,
remotePath: remotePath
)
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
do {
try await service.connect(to: connection.host, port: connection.port,
username: connection.username, password: connection.password)
service.disconnect()
isConnected = true
ConnectionStore.shared.savedConnection = connection
} catch let e as BackupError {
verifyError = e.errorDescription
} catch {
verifyError = error.localizedDescription
}
isVerifying = false
}
}

View File

@@ -0,0 +1,97 @@
import SwiftUI
struct HistoryView: View {
@EnvironmentObject var store: ConnectionStore
var body: some View {
ZStack {
Color.white.ignoresSafeArea()
if store.historyEntries.isEmpty {
VStack(spacing: 16) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 48))
.foregroundColor(AppTheme.textTertiary)
Text("No backup history yet")
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textSecondary)
}
} else {
List {
ForEach(store.historyEntries) { entry in
HistoryRowView(entry: entry)
.listRowSeparator(.visible)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.onDelete { offsets in
store.removeHistoryEntries(at: offsets)
}
}
.listStyle(.plain)
}
}
.navigationTitle("History")
.toolbar {
if !store.historyEntries.isEmpty {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Clear") { store.clearHistory() }
.foregroundColor(AppTheme.red)
}
}
}
}
}
struct HistoryRowView: View {
let entry: BackupHistoryEntry
var body: some View {
HStack(spacing: 14) {
Circle()
.fill(entry.failedCount == 0 ? AppTheme.green : AppTheme.orange)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.date, style: .date)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(AppTheme.textPrimary)
if entry.triggeredByLAN {
Text("AUTO")
.font(.system(size: 10, weight: .bold))
.foregroundColor(AppTheme.blue)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(AppTheme.blue, lineWidth: 1))
}
Spacer()
Text(entry.date, style: .time)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
}
HStack(spacing: 12) {
statChip("\(entry.uploadedCount) uploaded", color: AppTheme.textSecondary)
if entry.skippedCount > 0 {
statChip("\(entry.skippedCount) skipped", color: AppTheme.textTertiary)
}
if entry.failedCount > 0 {
statChip("\(entry.failedCount) errors", color: AppTheme.red)
}
}
Text(entry.nasHost)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private func statChip(_ text: String, color: Color) -> some View {
Text(text)
.font(AppTheme.captionFont)
.foregroundColor(color)
}
}

View File

@@ -0,0 +1,141 @@
import SwiftUI
struct LoginView: View {
@StateObject private var vm = LoginViewModel()
@State private var navigateToDashboard = false
var body: some View {
NavigationStack {
ZStack {
Color.white.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
// Logo + device
VStack(spacing: 16) {
Image("nas_logo_clean")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 22))
Text("Forge")
.font(.system(size: 22, weight: .bold))
.foregroundColor(AppTheme.textPrimary)
}
Spacer().frame(height: 40)
// Account avatar + name
HStack(spacing: 12) {
ZStack {
Circle()
.fill(AppTheme.blue)
.frame(width: 48, height: 48)
Text("AB")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text("Albert")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(AppTheme.textPrimary)
Text("NAS Account")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textSecondary)
}
}
.padding(.bottom, 32)
// Face ID button
Button(action: { vm.authenticateWithFaceID() }) {
HStack(spacing: 10) {
Image(systemName: "faceid")
.font(.system(size: 20, weight: .medium))
Text("Sign in with Face ID")
.font(.system(size: 17, weight: .semibold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 32)
.disabled(vm.isAuthenticating)
if let error = vm.authError {
Text(error)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.red)
.padding(.top, 12)
}
Spacer()
// More link
Button("More") { vm.showMoreSheet = true }
.font(.system(size: 15, weight: .regular))
.foregroundColor(AppTheme.textSecondary)
.padding(.bottom, 40)
}
}
.navigationDestination(isPresented: $navigateToDashboard) {
MainTabView()
}
.sheet(isPresented: $vm.showMoreSheet) {
MoreOptionsSheet(isPresented: $vm.showMoreSheet, onAuthenticated: {
vm.showMoreSheet = false
navigateToDashboard = true
})
.presentationDetents([.height(220)])
}
}
.onAppear {
vm.onAuthenticated = { navigateToDashboard = true }
}
}
}
struct MoreOptionsSheet: View {
@Binding var isPresented: Bool
var onAuthenticated: (() -> Void)?
@State private var showPasswordEntry = false
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Color(hex: "#D1D5DB"))
.frame(width: 40, height: 4)
.padding(.top, 12)
.padding(.bottom, 20)
Button("Password") { showPasswordEntry = true }
.font(.system(size: 17, weight: .semibold))
.foregroundColor(AppTheme.textPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
Divider()
Button("Another account") {
isPresented = false
onAuthenticated?()
}
.font(.system(size: 17, weight: .regular))
.foregroundColor(AppTheme.textPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
Divider()
Button("Cancel") { isPresented = false }
.font(.system(size: 17, weight: .regular))
.foregroundColor(AppTheme.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.background(Color.white)
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
import LocalAuthentication
@MainActor
final class LoginViewModel: ObservableObject {
@Published var showMoreSheet = false
@Published var isAuthenticating = false
@Published var authError: String? = nil
var onAuthenticated: (() -> Void)?
func authenticateWithFaceID() {
isAuthenticating = true
authError = nil
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
authError = "Face ID not available"
isAuthenticating = false
return
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Sign in to NASBackup"
) { [weak self] success, evalError in
Task { @MainActor in
self?.isAuthenticating = false
if success {
self?.onAuthenticated?()
} else {
self?.authError = evalError?.localizedDescription ?? "Authentication failed"
}
}
}
}
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
struct MainTabView: View {
@EnvironmentObject var store: ConnectionStore
var body: some View {
AdaptiveLayout()
}
}
// iPad uses NavigationSplitView; iPhone uses TabView
struct AdaptiveLayout: View {
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if sizeClass == .regular {
iPadSidebarLayout()
} else {
iPhoneTabLayout()
}
}
}
struct iPhoneTabLayout: View {
var body: some View {
TabView {
NavigationStack { BackupView() }
.tabItem { Label("Backup", systemImage: "arrow.up.doc.fill") }
NavigationStack { HistoryView() }
.tabItem { Label("History", systemImage: "clock.arrow.circlepath") }
NavigationStack {
if let conn = ConnectionStore.shared.savedConnection {
BrowseView(connection: conn, selectedPath: .constant("/"))
}
}
.tabItem { Label("Browse", systemImage: "folder.fill") }
NavigationStack { SettingsView() }
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
}
.tint(AppTheme.blue)
}
}
struct iPadSidebarLayout: View {
@State private var selection: String? = "backup"
var body: some View {
NavigationSplitView {
List(selection: $selection) {
Label("Backup", systemImage: "arrow.up.doc.fill").tag("backup")
Label("History", systemImage: "clock.arrow.circlepath").tag("history")
Label("Browse", systemImage: "folder.fill").tag("browse")
Label("Settings", systemImage: "gearshape.fill").tag("settings")
}
.navigationTitle("NASBackup")
} detail: {
switch selection {
case "backup": BackupView()
case "history": HistoryView()
case "browse":
if let conn = ConnectionStore.shared.savedConnection {
BrowseView(connection: conn, selectedPath: .constant("/"))
}
case "settings": SettingsView()
default: BackupView()
}
}
.tint(AppTheme.blue)
}
}

View File

@@ -0,0 +1,152 @@
import SwiftUI
struct LANTriggerSection: View {
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("AUTOMATION — LAN TRIGGER")
.font(AppTheme.smallFont)
.foregroundColor(AppTheme.textSecondary)
.padding(.leading, 4)
// Hero card blue border, FIXED 210px height, strict top-down layout
VStack(spacing: 0) {
// Header band
HStack {
Image(systemName: "wifi")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
Text("Auto-backup on home network")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
Spacer()
Text(store.lanTriggerEnabled ? "On" : "Off")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(store.lanTriggerEnabled ? Color.white.opacity(0.25) : Color.white.opacity(0.15))
.clipShape(Capsule())
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(AppTheme.blue)
// Toggle row
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Start when on trusted Wi-Fi")
.font(.system(size: 14, weight: .medium))
.foregroundColor(AppTheme.textPrimary)
Text("Backup runs on SSID match")
.font(.system(size: 12))
.foregroundColor(AppTheme.textSecondary)
}
Spacer()
Toggle("", isOn: $store.lanTriggerEnabled)
.labelsHidden()
.tint(AppTheme.blue)
.scaleEffect(0.85)
}
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 6)
Divider().padding(.horizontal, 14)
// Trusted networks
VStack(alignment: .leading, spacing: 4) {
Text("TRUSTED NETWORKS")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(AppTheme.textTertiary)
.padding(.horizontal, 14)
.padding(.top, 6)
ForEach(store.trustedSSIDs, id: \.self) { ssid in
HStack(spacing: 8) {
Circle()
.fill(ssid == lanMonitor.currentSSID ? AppTheme.green : AppTheme.textTertiary)
.frame(width: 7, height: 7)
Text(ssid == lanMonitor.currentSSID ? "Connected" : "Saved")
.font(.system(size: 11))
.foregroundColor(AppTheme.textTertiary)
.frame(width: 54, alignment: .leading)
Text(ssid)
.font(.system(size: 13, weight: .medium))
.foregroundColor(AppTheme.textPrimary)
Spacer()
Button {
store.trustedSSIDs.removeAll { $0 == ssid }
} label: {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .medium))
.foregroundColor(AppTheme.textTertiary)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 2)
}
Button {
Task {
if let ssid = await lanMonitor.fetchCurrentSSID(),
!store.trustedSSIDs.contains(ssid) {
store.trustedSSIDs.append(ssid)
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 13))
.foregroundColor(AppTheme.blue)
Text("Add current network")
.font(.system(size: 13, weight: .medium))
.foregroundColor(AppTheme.blue)
}
.padding(.horizontal, 14)
.padding(.vertical, 4)
}
}
Divider().padding(.horizontal, 14).padding(.top, 4)
// Bottom options row
HStack {
HStack(spacing: 6) {
Image(systemName: "timer")
.font(.system(size: 12))
.foregroundColor(AppTheme.textSecondary)
Text("Wait before starting")
.font(.system(size: 13))
.foregroundColor(AppTheme.textPrimary)
}
Spacer()
Menu {
ForEach([0, 15, 30, 60, 120], id: \.self) { sec in
Button("\(sec)s") { store.startDelaySeconds = sec }
}
} label: {
HStack(spacing: 4) {
Text("\(store.startDelaySeconds)s")
.font(.system(size: 13, weight: .medium))
.foregroundColor(AppTheme.blue)
Image(systemName: "chevron.right")
.font(.system(size: 11))
.foregroundColor(AppTheme.textTertiary)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
}
.frame(height: 210)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
.stroke(AppTheme.lanBorder, lineWidth: 1.5)
)
}
}
}

View File

@@ -0,0 +1,156 @@
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var store: ConnectionStore
var body: some View {
ZStack {
Color.white.ignoresSafeArea()
ScrollView {
VStack(spacing: AppTheme.sectionSpacing) {
// LAN trigger hero section
LANTriggerSection()
// What to back up
mediaSection
// Quiet hours
quietHoursSection
// Notification section
notificationSection
Spacer(minLength: 40)
}
.padding(.horizontal, 20)
.padding(.top, 20)
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
}
// MARK: Media section
private var mediaSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("WHAT TO BACK UP")
.font(AppTheme.smallFont)
.foregroundColor(AppTheme.textSecondary)
.padding(.leading, 4)
VStack(spacing: 0) {
ToggleRow(icon: "photo", title: "Photos",
isOn: $store.backupFilter.includePhotos)
Divider().padding(.leading, 44)
ToggleRow(icon: "video", title: "Videos",
isOn: $store.backupFilter.includeVideos)
Divider().padding(.leading, 44)
ToggleRow(icon: "camera.viewfinder", title: "Screenshots",
isOn: $store.backupFilter.includeScreenshots)
Divider().padding(.leading, 44)
ToggleRow(icon: "rays", title: "RAW",
isOn: $store.backupFilter.includeRAW)
Divider().padding(.leading, 44)
ToggleRow(icon: "doc.badge.clock", title: "New files only",
subtitle: "Skip files already on NAS",
isOn: $store.backupFilter.newFilesOnly)
}
.padding(.horizontal, 16)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder))
}
}
// MARK: Quiet hours
private var quietHoursSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("QUIET HOURS")
.font(AppTheme.smallFont)
.foregroundColor(AppTheme.textSecondary)
.padding(.leading, 4)
VStack(spacing: 0) {
ToggleRow(icon: "moon.fill", title: "Quiet hours",
subtitle: "No auto-backup during this window",
isOn: $store.quietHoursEnabled)
if store.quietHoursEnabled {
Divider().padding(.leading, 44)
timePickerRow(label: "Start", hour: $store.quietHoursStart)
Divider().padding(.leading, 44)
timePickerRow(label: "End", hour: $store.quietHoursEnd)
}
Divider().padding(.leading, 44)
ToggleRow(icon: "bolt.fill", title: "Only while charging",
subtitle: "Preserve battery on large libraries",
isOn: $store.chargingOnlyMode)
}
.padding(.horizontal, 16)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder))
}
}
private func timePickerRow(label: String, hour: Binding<Int>) -> some View {
HStack {
Text(label)
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textPrimary)
.frame(width: 80, alignment: .leading)
Spacer()
Picker(label, selection: hour) {
ForEach(0..<24, id: \.self) { h in
Text(String(format: "%02d:00", h)).tag(h)
}
}
.pickerStyle(.menu)
.tint(AppTheme.blue)
}
.padding(.vertical, 8)
}
// MARK: Notifications
private var notificationSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("NOTIFICATIONS")
.font(AppTheme.smallFont)
.foregroundColor(AppTheme.textSecondary)
.padding(.leading, 4)
VStack(spacing: 0) {
staticRow(icon: "bell.fill", title: "On start", value: "Off")
Divider().padding(.leading, 44)
staticRow(icon: "checkmark.circle.fill", title: "On complete", value: "On")
Divider().padding(.leading, 44)
staticRow(icon: "exclamationmark.triangle.fill", title: "On errors", value: "On")
}
.padding(.horizontal, 16)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder))
}
}
private func staticRow(icon: String, title: String, value: String) -> some View {
HStack {
Image(systemName: icon)
.foregroundColor(AppTheme.blue)
.frame(width: 28)
Text(title)
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textPrimary)
Spacer()
Text(value)
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textSecondary)
}
.padding(.vertical, 12)
}
}