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

@@ -4,8 +4,12 @@ struct ConnectView: View {
@StateObject private var vm = ConnectViewModel()
@State private var navigateToDashboard = false
@State private var showPassword = false
@State private var folderPickerPath = "/"
@FocusState private var focused: ConnectField?
private var isAuthenticated: Bool { vm.connectionState.isAuthenticated }
private var isFailed: Bool { vm.connectionState.errorMessage != nil }
var body: some View {
NavigationStack {
ZStack {
@@ -13,287 +17,384 @@ struct ConnectView: View {
ScrollView {
VStack(spacing: 0) {
// Header identical treatment to BackupView
VStack(spacing: 10) {
Text("kisani.")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(AppTheme.ink)
.kerning(2)
.padding(.top, 52)
KisaniLogoMark(size: 66)
UGreenCompatibilityBadge(selectedProtocol: vm.selectedProtocol)
.padding(.top, 4)
}
.frame(maxWidth: .infinity)
.padding(.bottom, 28)
// Protocol picker
HStack(spacing: 0) {
ForEach(NASProtocol.allCases, id: \.self) { proto in
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
vm.selectedProtocol = proto
vm.isConnected = false
vm.verifyError = nil
}
} label: {
Text(proto.rawValue)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(vm.selectedProtocol == proto ? AppTheme.ink : AppTheme.inkTertiary)
.frame(maxWidth: .infinity)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : Color.clear)
.shadow(
color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
radius: 3, x: 0, y: 1
)
)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(3)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
// Step 1: Host
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Image(systemName: "network")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
TextField("IP address or hostname", text: $vm.host)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused, equals: .host)
.submitLabel(.next)
.onSubmit { focused = .username }
if !vm.host.isEmpty {
Button { vm.host = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(AppTheme.inkQuaternary)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
Text("e.g. 192.168.1.10 or MY_NAS")
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary)
.padding(.leading, 4)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
// Step 1: Credentials card
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: "person")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
TextField("Username", text: $vm.username)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused, equals: .username)
.submitLabel(.next)
.onSubmit { focused = .password }
if !vm.username.isEmpty {
Button { vm.username = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(AppTheme.inkQuaternary)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, 44)
HStack(spacing: 12) {
Image(systemName: "lock")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
Group {
if showPassword {
TextField("Password", text: $vm.password)
} else {
SecureField("Password", text: $vm.password)
}
}
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.focused($focused, equals: .password)
.submitLabel(.go)
.onSubmit { Task { await vm.verify() } }
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash" : "eye")
.font(.system(size: 14))
.foregroundStyle(AppTheme.inkTertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke(
vm.isConnected ? AppTheme.positive.opacity(0.45) :
vm.verifyError != nil ? AppTheme.destructive.opacity(0.45) :
Color.clear,
lineWidth: 1
)
)
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError != nil)
// Destination folder always visible, active only after auth
Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) {
HStack(spacing: 12) {
Image(systemName: vm.remotePath == "/" ? "folder" : "folder.fill")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(
!vm.isConnected ? AppTheme.inkQuaternary :
vm.remotePath == "/" ? AppTheme.inkSecondary : AppTheme.positive
)
.frame(width: 20)
Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath)
.font(AppTheme.body())
.foregroundStyle(
!vm.isConnected ? AppTheme.inkQuaternary :
vm.remotePath == "/" ? AppTheme.inkTertiary : AppTheme.ink
)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "folder.badge.plus")
.font(.system(size: 16))
.foregroundStyle(vm.isConnected ? AppTheme.inkSecondary : AppTheme.inkQuaternary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke(
vm.step == .ready ? AppTheme.positive.opacity(0.45) : Color.clear,
lineWidth: 1
)
)
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.opacity(vm.isConnected ? 1 : 0.45)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
}
.buttonStyle(ScaleButtonStyle())
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
// Status message
HStack(spacing: 6) {
Image(systemName: vm.statusIsError ? "exclamationmark.circle" :
vm.statusIsPositive ? "checkmark.circle" : "circle.dotted")
.font(.system(size: 13))
Text(vm.statusMessage)
.font(AppTheme.caption())
}
.foregroundStyle(
vm.statusIsError ? AppTheme.destructive :
vm.statusIsPositive ? AppTheme.positive :
AppTheme.inkTertiary
)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.step)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError)
headerSection
protocolPicker
hostField
credentialsCard
connectedBanner
destinationRow
statusLine
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
Button(action: {
focused = nil
if vm.step == .ready {
navigateToDashboard = true
} else {
Task { await vm.proceed() }
}
}) {
Group {
if vm.isVerifying {
ProgressView().tint(.white)
} else {
Text(ctaLabel)
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(PrimaryButtonStyle(color: ctaColor))
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 12)
.padding(.bottom, 32)
.disabled(!vm.canProceed)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.step)
}
.background(AppTheme.background)
}
.scrollDismissesKeyboard(.interactively)
.safeAreaInset(edge: .bottom) { ctaBar }
}
.navigationBarHidden(true)
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
.sheet(isPresented: $vm.showFolderBrowser) {
if let conn = ConnectionStore.shared.savedConnection {
BrowseView(connection: conn, selectedPath: $vm.remotePath, isPickerMode: true)
.onDisappear {
if vm.remotePath != "/", var c = ConnectionStore.shared.savedConnection {
c.remotePath = vm.remotePath
ConnectionStore.shared.savedConnection = c
}
}
}
}
.sheet(isPresented: $vm.showFolderBrowser) { folderSheet }
}
}
// MARK: Header
private var headerSection: some View {
VStack(spacing: 10) {
Text("kisani.")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(AppTheme.ink)
.kerning(2)
.padding(.top, 52)
KisaniLogoMark(size: 66)
UGreenCompatibilityBadge(selectedProtocol: vm.selectedProtocol)
.padding(.top, 4)
}
.frame(maxWidth: .infinity)
.padding(.bottom, 28)
}
// MARK: Protocol picker
private var protocolPicker: some View {
HStack(spacing: 0) {
ForEach(NASProtocol.allCases, id: \.self) { proto in
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
vm.selectedProtocol = proto
vm.resetAuth()
}
} label: {
Text(proto.rawValue)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(vm.selectedProtocol == proto ? AppTheme.ink : AppTheme.inkTertiary)
.frame(maxWidth: .infinity)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : .clear)
.shadow(color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
radius: 3, x: 0, y: 1)
)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(3)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
}
// MARK: Host field
private var hostField: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Image(systemName: "network")
.font(.system(size: 14))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
TextField("IP address or hostname", text: $vm.host)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused, equals: .host)
.submitLabel(.next)
.onSubmit { focused = .username }
.onChange(of: vm.host) { _ in vm.resetAuth() }
if !vm.host.isEmpty {
Button { vm.host = ""; vm.resetAuth() } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(AppTheme.inkQuaternary)
}
}
}
.padding(.horizontal, 16).padding(.vertical, 14)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
Text("e.g. 192.168.1.10 or MY_NAS")
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary)
.padding(.leading, 4)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
}
// MARK: Credentials card
private var credentialsCard: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: "person")
.font(.system(size: 14))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
TextField("Username", text: $vm.username)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused, equals: .username)
.submitLabel(.next)
.onSubmit { focused = .password }
.onChange(of: vm.username) { _ in vm.resetAuth() }
if !vm.username.isEmpty {
Button { vm.username = ""; vm.resetAuth() } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(AppTheme.inkQuaternary)
}
}
}
.padding(.horizontal, 16).padding(.vertical, 14)
Rectangle().fill(AppTheme.separator).frame(height: 0.5).padding(.leading, 44)
HStack(spacing: 12) {
Image(systemName: "lock")
.font(.system(size: 14))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
Group {
if showPassword {
TextField("Password", text: $vm.password)
} else {
SecureField("Password", text: $vm.password)
}
}
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.focused($focused, equals: .password)
.submitLabel(.go)
.onSubmit { focused = nil; Task { await vm.authenticate() } }
.onChange(of: vm.password) { _ in vm.resetAuth() }
Button { showPassword.toggle() } label: {
Image(systemName: showPassword ? "eye.slash" : "eye")
.font(.system(size: 14))
.foregroundStyle(AppTheme.inkTertiary)
}
}
.padding(.horizontal, 16).padding(.vertical, 14)
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke(cardBorderColor, lineWidth: 1)
)
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: isAuthenticated)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: isFailed)
}
private var cardBorderColor: Color {
if isAuthenticated { return AppTheme.positive.opacity(0.5) }
if isFailed { return AppTheme.destructive.opacity(0.4) }
return .clear
}
// MARK: Connected banner (appears after auth)
@ViewBuilder
private var connectedBanner: some View {
if isAuthenticated {
HStack(spacing: 8) {
Circle()
.fill(AppTheme.positive)
.frame(width: 7, height: 7)
Text("Connected to \(vm.host)")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkSecondary)
Spacer()
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { vm.resetAuth() }
} label: {
Text("Disconnect")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// MARK: Destination row
private var destinationRow: some View {
Button(action: { if isAuthenticated { vm.showFolderBrowser = true } }) {
HStack(spacing: 12) {
Image(systemName: vm.selectedDestination.isEmpty ? "folder" : "folder.fill")
.font(.system(size: 14))
.foregroundStyle(destinationIconColor)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(vm.selectedDestination.isEmpty
? (isAuthenticated ? "Select destination folder" : "Connect to your NAS first")
: vm.selectedDestination)
.font(AppTheme.body())
.foregroundStyle(destinationTextColor)
.lineLimit(2)
.multilineTextAlignment(.leading)
if vm.connectionState == .destinationSelected {
Text("Backup destination ready")
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.positive)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: isAuthenticated ? "chevron.right" : "lock")
.font(.system(size: isAuthenticated ? 12 : 11, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 16).padding(.vertical, 14)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke(vm.connectionState == .destinationSelected
? AppTheme.positive.opacity(0.5) : Color.clear, lineWidth: 1)
)
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.opacity(isAuthenticated ? 1 : 0.4)
}
.buttonStyle(ScaleButtonStyle())
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
.allowsHitTesting(isAuthenticated)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isAuthenticated)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.connectionState)
}
private var destinationIconColor: Color {
if !isAuthenticated { return AppTheme.inkQuaternary }
return vm.selectedDestination.isEmpty ? AppTheme.inkSecondary : AppTheme.positive
}
private var destinationTextColor: Color {
if !isAuthenticated { return AppTheme.inkQuaternary }
return vm.selectedDestination.isEmpty ? AppTheme.inkTertiary : AppTheme.ink
}
// MARK: Status line
private var statusLine: some View {
HStack(spacing: 6) {
Image(systemName: statusIcon)
.font(.system(size: 12))
Text(vm.statusMessage)
.font(AppTheme.caption())
}
.foregroundStyle(statusColor)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.connectionState)
}
private var statusIcon: String {
switch vm.connectionState {
case .idle: return "circle.dotted"
case .authenticating: return "arrow.trianglehead.2.clockwise"
case .authenticated: return "checkmark.circle"
case .destinationSelected: return "checkmark.circle.fill"
case .failed: return "exclamationmark.circle"
}
}
private var statusColor: Color {
switch vm.connectionState {
case .idle, .authenticating: return AppTheme.inkTertiary
case .authenticated: return AppTheme.positive
case .destinationSelected: return AppTheme.positive
case .failed: return AppTheme.destructive
}
}
// MARK: CTA bar
private var ctaBar: some View {
VStack(spacing: 0) {
Button(action: handleCTA) {
Group {
if vm.connectionState == .authenticating {
ProgressView().tint(.white)
} else {
Text(ctaLabel)
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(PrimaryButtonStyle(color: ctaColor))
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 12)
.padding(.bottom, 32)
.disabled(!ctaEnabled)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.connectionState)
}
.background(AppTheme.background)
}
private var ctaLabel: String {
switch vm.step {
case .credentials: return "Connect"
case .folder: return "Select Destination"
case .ready: return "Get Started"
switch vm.connectionState {
case .idle, .failed: return "Connect"
case .authenticating: return "Connecting…"
case .authenticated: return "Choose Destination"
case .destinationSelected: return "Get Started"
}
}
private var ctaColor: Color {
switch vm.step {
case .credentials: return AppTheme.ink
case .folder: return AppTheme.ink
case .ready: return AppTheme.positive
vm.connectionState == .destinationSelected ? AppTheme.positive : AppTheme.ink
}
private var ctaEnabled: Bool {
switch vm.connectionState {
case .idle, .failed: return vm.canConnect
case .authenticating: return false
case .authenticated: return true
case .destinationSelected: return true
}
}
private func handleCTA() {
focused = nil
switch vm.connectionState {
case .idle, .failed:
Task { await vm.authenticate() }
case .authenticating:
break
case .authenticated:
vm.showFolderBrowser = true
case .destinationSelected:
navigateToDashboard = true
}
}
// MARK: Folder sheet
private var folderSheet: some View {
Group {
if let conn = ConnectionStore.shared.savedConnection {
BrowseView(connection: conn, selectedPath: $folderPickerPath, isPickerMode: true)
.onDisappear {
if folderPickerPath != "/" {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
vm.confirmDestination(folderPickerPath)
}
}
}
}
}
}
}

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