import SwiftUI // MARK: - AuthView // Full-screen login / register view. // Mirrors the web UI's login page: zinc-900 background, tab switcher with // amber underline indicator, zinc-800 text fields with amber focus ring, // amber CTA button, inline error banner, loading state. struct AuthView: View { @EnvironmentObject var authStore: AuthStore @EnvironmentObject var networkMonitor: NetworkMonitor @State private var mode: AuthMode = .login // Login fields @State private var loginUsername: String = "" @State private var loginPassword: String = "" // Register fields @State private var regUsername: String = "" @State private var regPassword: String = "" @State private var regConfirm: String = "" // Focus management @FocusState private var focus: AuthField? // Local validation error (client-side, e.g. password mismatch) @State private var localError: String? private var displayError: String? { localError ?? authStore.error } var body: some View { ZStack { Color.appBackground.ignoresSafeArea() ScrollView { VStack(spacing: 0) { Spacer(minLength: 60) // ── Wordmark ────────────────────────────────────────── wordmark Spacer(minLength: 48) // ── Card ────────────────────────────────────────────── VStack(spacing: 0) { tabSwitcher formContent } .background(Color.cardBackground) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .padding(.horizontal, 24) Spacer(minLength: 40) } } .scrollDismissesKeyboard(.interactively) } .onChange(of: mode) { _, _ in localError = nil authStore.error = nil } } // MARK: - Wordmark private var wordmark: some View { VStack(spacing: 6) { Image(systemName: "books.vertical.fill") .font(.system(size: 44)) .foregroundStyle(Color.amber) .symbolEffect(.bounce, value: mode) Text("libnovel") .font(.title.bold()) .fontDesign(.serif) .foregroundStyle(.primary) } } // MARK: - Tab switcher private var tabSwitcher: some View { HStack(spacing: 0) { tabButton(label: "Sign in", tab: .login) tabButton(label: "Create account", tab: .register) } .overlay(alignment: .bottom) { Rectangle() .fill(Color.cardBorder) .frame(height: 1) } } @ViewBuilder private func tabButton(label: String, tab: AuthMode) -> some View { let isActive = mode == tab Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { mode = tab } } label: { VStack(spacing: 0) { Text(label) .font(.subheadline.weight(.medium)) .foregroundStyle(isActive ? Color.amber : Color.secondary) .padding(.vertical, 14) .frame(maxWidth: .infinity) // Active underline indicator Rectangle() .fill(isActive ? Color.amber : Color.clear) .frame(height: 2) .offset(y: 1) // sits on top of the border } } .accessibilityAddTraits(isActive ? [.isSelected] : []) } // MARK: - Form content @ViewBuilder private var formContent: some View { VStack(spacing: 16) { // Error banner if let err = displayError { errorBanner(err) .transition(.move(edge: .top).combined(with: .opacity)) } switch mode { case .login: loginForm case .register: registerForm } } .padding(20) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: displayError) .animation(.spring(response: 0.35, dampingFraction: 0.75), value: mode) } // MARK: - Error banner private func errorBanner(_ message: String) -> some View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Color.errorText) .font(.footnote) Text(message) .font(.footnote) .foregroundStyle(Color.errorText) .multilineTextAlignment(.leading) } .padding(.horizontal, 12) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.errorBackground) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.errorBorder, lineWidth: 1) ) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } // MARK: - Login form private var loginForm: some View { VStack(spacing: 16) { AuthInputField( label: "Username", placeholder: "your_username", text: $loginUsername, contentType: .username, keyboardType: .default, focusState: $focus, field: .loginUsername, nextField: .loginPassword ) AuthInputField( label: "Password", placeholder: "••••••••", text: $loginPassword, contentType: .password, isSecure: true, focusState: $focus, field: .loginPassword, onSubmit: submitLogin ) ctaButton(label: "Sign in", action: submitLogin) } } // MARK: - Register form private var registerForm: some View { VStack(spacing: 16) { VStack(spacing: 4) { AuthInputField( label: "Username", placeholder: "your_username", text: $regUsername, contentType: .username, focusState: $focus, field: .regUsername, nextField: .regPassword ) Text("3–32 characters: letters, numbers, _ or -") .font(.caption) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 2) } VStack(spacing: 4) { AuthInputField( label: "Password", placeholder: "••••••••", text: $regPassword, contentType: .newPassword, isSecure: true, focusState: $focus, field: .regPassword, nextField: .regConfirm ) Text("At least 8 characters") .font(.caption) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 2) } AuthInputField( label: "Confirm password", placeholder: "••••••••", text: $regConfirm, contentType: .newPassword, isSecure: true, focusState: $focus, field: .regConfirm, onSubmit: submitRegister ) ctaButton(label: "Create account", action: submitRegister) } } // MARK: - CTA button private func ctaButton(label: String, action: @escaping () -> Void) -> some View { Button(action: action) { ZStack { if authStore.isLoading { ProgressView() .tint(Color(uiColor: .systemBackground)) } else { Text(label) .font(.subheadline.bold()) .foregroundStyle(Color.ctaText) } } .frame(maxWidth: .infinity) .frame(height: 44) } .background(Color.amber) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .disabled(authStore.isLoading || !networkMonitor.isConnected) .opacity(authStore.isLoading ? 0.8 : 1) .animation(.easeInOut(duration: 0.15), value: authStore.isLoading) .accessibilityLabel(label) } // MARK: - Actions private func submitLogin() { guard !authStore.isLoading else { return } localError = nil focus = nil UIImpactFeedbackGenerator(style: .medium).impactOccurred() Task { await authStore.login(username: loginUsername, password: loginPassword) } } private func submitRegister() { guard !authStore.isLoading else { return } localError = nil // Client-side validation if regUsername.count < 3 || regUsername.count > 32 { localError = "Username must be 3–32 characters." return } if regPassword.count < 8 { localError = "Password must be at least 8 characters." return } if regPassword != regConfirm { localError = "Passwords do not match." return } focus = nil UIImpactFeedbackGenerator(style: .medium).impactOccurred() Task { await authStore.register(username: regUsername, password: regPassword) } } } // MARK: - Auth mode enum private enum AuthMode: Equatable { case login, register } // MARK: - Focus field enum private enum AuthField: Hashable { case loginUsername, loginPassword case regUsername, regPassword, regConfirm } // MARK: - AuthInputField component private struct AuthInputField: View { let label: String let placeholder: String @Binding var text: String var contentType: UITextContentType? = nil var keyboardType: UIKeyboardType = .default var isSecure: Bool = false @FocusState.Binding var focusState: AuthField? let field: AuthField var nextField: AuthField? = nil var onSubmit: (() -> Void)? = nil private var isFocused: Bool { focusState == field } var body: some View { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.caption) .foregroundStyle(.secondary) Group { if isSecure { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text) .keyboardType(keyboardType) .autocorrectionDisabled() .textInputAutocapitalization(.never) } } .textContentType(contentType) .focused($focusState, equals: field) .submitLabel(nextField != nil ? .next : .done) .onSubmit { if let next = nextField { focusState = next } else { onSubmit?() } } .padding(.horizontal, 12) .frame(height: 44) .background(Color.fieldBackground) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke( isFocused ? Color.amber : Color.cardBorder, lineWidth: isFocused ? 1.5 : 1 ) ) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .animation(.spring(response: 0.2, dampingFraction: 0.7), value: isFocused) } } } // MARK: - Local color helpers private extension Color { static let appBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.09, green: 0.09, blue: 0.11, alpha: 1) : UIColor.systemGroupedBackground }) static let cardBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1) : UIColor.secondarySystemGroupedBackground }) static let cardBorder = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(white: 0.25, alpha: 1) : UIColor.separator }) static let fieldBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.11, green: 0.11, blue: 0.13, alpha: 1) : UIColor.secondarySystemBackground }) static let ctaText = Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)) // zinc-900 static let errorBackground = Color(red: 0.40, green: 0.05, blue: 0.05).opacity(0.40) static let errorBorder = Color(red: 0.70, green: 0.20, blue: 0.20).opacity(0.60) static let errorText = Color(red: 0.98, green: 0.60, blue: 0.60) }