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:
Robin Kutesa
2026-05-17 15:03:12 +03:00
parent 44f290da54
commit c11ce6d644
2 changed files with 435 additions and 343 deletions

View File

@@ -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 = ""
}
}