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