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) } } } } } } }