Introduce ConnectionState enum (idle → authenticating → authenticated → destinationSelected → failed) replacing the old Bool isConnected flag. The entire UI renders reactively from this single state value. Authentication: vm.authenticate() drives the state, haptic feedback fires on success, any field edit resets back to idle so the user always knows their current auth is stale after editing. Connected banner: slides in below the credentials card after auth showing "Connected to [host]" with a green dot and a Disconnect button that resets state without clearing credentials. Destination row: always visible at 40% opacity with a lock icon and placeholder text "Connect to your NAS first" — activates to full opacity with chevron and browsable state after auth. Green border + subtitle "Backup destination ready" appears after selection. Taps blocked via allowsHitTesting until authenticated. CTA progression: idle/failed → "Connect" (enabled when fields filled) authenticating → spinner, disabled authenticated → "Choose Destination" (opens folder browser) destinationSelected → "Get Started" in green → navigates to dashboard After folder browser dismissal, confirmDestination() writes the path to ConnectionStore and fires a light haptic. Navigation to MainTabView happens only when state == .destinationSelected. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
108 lines
3.7 KiB
Swift
108 lines
3.7 KiB
Swift
import Foundation
|
|
import UIKit
|
|
|
|
enum ConnectField { case host, username, password }
|
|
|
|
enum ConnectionState: Equatable {
|
|
case idle
|
|
case authenticating
|
|
case authenticated
|
|
case destinationSelected
|
|
case failed(String)
|
|
|
|
var isAuthenticated: Bool {
|
|
switch self { case .authenticated, .destinationSelected: return true; default: return false }
|
|
}
|
|
var errorMessage: String? {
|
|
if case .failed(let msg) = self { return msg }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ConnectViewModel: ObservableObject {
|
|
@Published var host: String = ""
|
|
@Published var username: String = ""
|
|
@Published var password: String = ""
|
|
@Published var selectedProtocol: NASProtocol = .smb
|
|
@Published var selectedDestination: String = ""
|
|
@Published var connectionState: ConnectionState = .idle
|
|
@Published var showFolderBrowser = false
|
|
|
|
init() {
|
|
if let conn = ConnectionStore.shared.savedConnection {
|
|
host = conn.host
|
|
username = conn.username
|
|
password = conn.password
|
|
selectedProtocol = conn.nasProtocol
|
|
selectedDestination = conn.remotePath == "/" ? "" : conn.remotePath
|
|
connectionState = conn.remotePath == "/" ? .authenticated : .destinationSelected
|
|
}
|
|
}
|
|
|
|
var canConnect: Bool {
|
|
!host.trimmingCharacters(in: .whitespaces).isEmpty
|
|
&& !username.isEmpty
|
|
&& !password.isEmpty
|
|
&& connectionState != .authenticating
|
|
}
|
|
|
|
var canContinue: Bool {
|
|
connectionState == .destinationSelected && !selectedDestination.isEmpty
|
|
}
|
|
|
|
var statusMessage: String {
|
|
switch connectionState {
|
|
case .idle: return "Connect to your NAS to choose a destination"
|
|
case .authenticating: return "Connecting to NAS…"
|
|
case .authenticated: return "Authenticated — select a destination folder"
|
|
case .destinationSelected: return "Backup destination ready"
|
|
case .failed(let msg): return msg
|
|
}
|
|
}
|
|
|
|
func authenticate() async {
|
|
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
|
connectionState = .authenticating
|
|
|
|
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
|
|
let connection = NASConnection(
|
|
host: trimmedHost,
|
|
nasProtocol: selectedProtocol,
|
|
username: username,
|
|
password: password,
|
|
remotePath: selectedDestination.isEmpty ? "/" : selectedDestination
|
|
)
|
|
|
|
do {
|
|
try await service.connect(
|
|
to: connection.host, port: connection.port,
|
|
username: connection.username, password: connection.password
|
|
)
|
|
service.disconnect()
|
|
ConnectionStore.shared.savedConnection = connection
|
|
connectionState = .authenticated
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
} catch let e as BackupError {
|
|
connectionState = .failed(e.errorDescription ?? e.localizedDescription)
|
|
} catch {
|
|
connectionState = .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func confirmDestination(_ path: String) {
|
|
guard !path.isEmpty, path != "/" else { return }
|
|
selectedDestination = path
|
|
connectionState = .destinationSelected
|
|
guard var conn = ConnectionStore.shared.savedConnection else { return }
|
|
conn.remotePath = path
|
|
ConnectionStore.shared.savedConnection = conn
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
}
|
|
|
|
func resetAuth() {
|
|
connectionState = .idle
|
|
selectedDestination = ""
|
|
}
|
|
}
|