From c11ce6d64491914f865e109d8e1f2f031a3d1b64 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 17 May 2026 15:03:12 +0300 Subject: [PATCH] Overhaul Connect flow with proper state machine and progressive UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Sentry --- Features/Connect/ConnectView.swift | 637 ++++++++++++++---------- Features/Connect/ConnectViewModel.swift | 141 +++--- 2 files changed, 435 insertions(+), 343 deletions(-) diff --git a/Features/Connect/ConnectView.swift b/Features/Connect/ConnectView.swift index 61f5f9b..96cd449 100644 --- a/Features/Connect/ConnectView.swift +++ b/Features/Connect/ConnectView.swift @@ -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) + } + } + } + } } } } + diff --git a/Features/Connect/ConnectViewModel.swift b/Features/Connect/ConnectViewModel.swift index 3d639ae..0e13268 100644 --- a/Features/Connect/ConnectViewModel.swift +++ b/Features/Connect/ConnectViewModel.swift @@ -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 = "" } }