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>
401 lines
16 KiB
Swift
401 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|