Files
libnovel/ios/LibNovelV2/Views/Auth/AuthView.swift
Admin 7413313100
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

387 lines
13 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
// 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("332 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 332 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)
}