Overhaul Connect flow with proper state machine and progressive UX
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>
This commit is contained in:
@@ -1,116 +1,107 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum ConnectField { case host, username, password }
|
||||
|
||||
enum ConnectStep: Equatable {
|
||||
case credentials // not yet authenticated
|
||||
case folder // authenticated, no destination chosen
|
||||
case ready // authenticated + valid destination chosen
|
||||
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 remotePath: String
|
||||
@Published var selectedProtocol: NASProtocol
|
||||
@Published var isVerifying = false
|
||||
@Published var isConnected: Bool
|
||||
@Published var verifyError: String? = nil
|
||||
@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
|
||||
remotePath = conn.remotePath
|
||||
selectedProtocol = conn.nasProtocol
|
||||
isConnected = true
|
||||
} else {
|
||||
host = ""
|
||||
username = ""
|
||||
password = ""
|
||||
remotePath = "/"
|
||||
selectedProtocol = .smb
|
||||
isConnected = false
|
||||
host = conn.host
|
||||
username = conn.username
|
||||
password = conn.password
|
||||
selectedProtocol = conn.nasProtocol
|
||||
selectedDestination = conn.remotePath == "/" ? "" : conn.remotePath
|
||||
connectionState = conn.remotePath == "/" ? .authenticated : .destinationSelected
|
||||
}
|
||||
}
|
||||
|
||||
var step: ConnectStep {
|
||||
guard isConnected else { return .credentials }
|
||||
return remotePath == "/" ? .folder : .ready
|
||||
var canConnect: Bool {
|
||||
!host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !username.isEmpty
|
||||
&& !password.isEmpty
|
||||
&& connectionState != .authenticating
|
||||
}
|
||||
|
||||
var canProceed: Bool {
|
||||
switch step {
|
||||
case .credentials:
|
||||
return !host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !username.isEmpty
|
||||
&& !password.isEmpty
|
||||
&& !isVerifying
|
||||
case .folder, .ready:
|
||||
return true
|
||||
}
|
||||
var canContinue: Bool {
|
||||
connectionState == .destinationSelected && !selectedDestination.isEmpty
|
||||
}
|
||||
|
||||
var statusMessage: String {
|
||||
switch step {
|
||||
case .credentials:
|
||||
return verifyError != nil ? verifyError! : "Enter your NAS credentials"
|
||||
case .folder:
|
||||
return "Authenticated · select a destination folder"
|
||||
case .ready:
|
||||
return "Ready to sync"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
var statusIsError: Bool { verifyError != nil && step == .credentials }
|
||||
var statusIsPositive: Bool { step == .folder || step == .ready }
|
||||
func authenticate() async {
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||
connectionState = .authenticating
|
||||
|
||||
func proceed() async {
|
||||
switch step {
|
||||
case .credentials:
|
||||
await verify()
|
||||
case .folder:
|
||||
showFolderBrowser = true
|
||||
case .ready:
|
||||
break // navigation handled in the view
|
||||
}
|
||||
}
|
||||
|
||||
func verify() async {
|
||||
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
|
||||
await verifyWith(transfer: service)
|
||||
}
|
||||
|
||||
func verifyWith(transfer: any NASTransferProtocol) async {
|
||||
isVerifying = true
|
||||
isConnected = false
|
||||
verifyError = nil
|
||||
|
||||
let connection = NASConnection(
|
||||
host: host.trimmingCharacters(in: .whitespaces),
|
||||
host: trimmedHost,
|
||||
nasProtocol: selectedProtocol,
|
||||
username: username,
|
||||
password: password,
|
||||
remotePath: remotePath
|
||||
remotePath: selectedDestination.isEmpty ? "/" : selectedDestination
|
||||
)
|
||||
|
||||
do {
|
||||
try await transfer.connect(
|
||||
try await service.connect(
|
||||
to: connection.host, port: connection.port,
|
||||
username: connection.username, password: connection.password
|
||||
)
|
||||
transfer.disconnect()
|
||||
isConnected = true
|
||||
service.disconnect()
|
||||
ConnectionStore.shared.savedConnection = connection
|
||||
connectionState = .authenticated
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
} catch let e as BackupError {
|
||||
verifyError = e.errorDescription
|
||||
connectionState = .failed(e.errorDescription ?? e.localizedDescription)
|
||||
} catch {
|
||||
verifyError = error.localizedDescription
|
||||
connectionState = .failed(error.localizedDescription)
|
||||
}
|
||||
isVerifying = false
|
||||
}
|
||||
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user