Files
Kisani/Features/Connect/ConnectViewModel.swift
Robin Kutesa c11ce6d644 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>
2026-05-17 15:03:12 +03:00

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