import SwiftUI struct ConnectView: View { @StateObject private var vm = ConnectViewModel() @State private var navigateToDashboard = false @State private var showPassword = false @FocusState private var focused: ConnectField? var body: some View { NavigationStack { ZStack { AppTheme.background.ignoresSafeArea() 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) } } .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) } } .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 } } } } } } private var ctaLabel: String { switch vm.step { case .credentials: return "Connect" case .folder: return "Select Destination" case .ready: 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 } } }