Files
Kisani/Features/Connect/ConnectView.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

401 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
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 {
AppTheme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
headerSection
protocolPicker
hostField
credentialsCard
connectedBanner
destinationRow
statusLine
}
}
.scrollDismissesKeyboard(.interactively)
.safeAreaInset(edge: .bottom) { ctaBar }
}
.navigationBarHidden(true)
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
.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.connectionState {
case .idle, .failed: return "Connect"
case .authenticating: return "Connecting…"
case .authenticated: return "Choose Destination"
case .destinationSelected: return "Get Started"
}
}
private var ctaColor: Color {
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)
}
}
}
}
}
}
}