- iOS: Add KeychainHelper.swift for secure token storage - iOS: Add AuthService.swift (CMAuthService) with login/register/refresh/logout - iOS: Add LoginView.swift (CMLoginView) with ChronoMind theme - iOS: Wire auth gate in ChronoMindApp.swift (LoginView vs ContentView) - iOS: Add Account section to SettingsView with email/plan/sign-out - iOS: Add Cloud group + 3 files to Xcode project.pbxproj - Android: Add AuthService.kt with Hilt @Singleton, login/register/refresh/logout - Android: Add LoginScreen.kt with Compose login/register form - Android: Wire auth gate in MainActivity via Hilt-injected AuthService - Android: Add Account section to SettingsScreen via HiltViewModel - Android: Add x-product-id header to PlatformApiClient
147 lines
5.8 KiB
Swift
147 lines
5.8 KiB
Swift
// ── Login / Register View ─────────────────────────────────────
|
|
// Authentication form for ChronoMind via platform-service.
|
|
|
|
import SwiftUI
|
|
|
|
struct CMLoginView: View {
|
|
@ObservedObject var authService = CMAuthService.shared
|
|
|
|
@State private var isRegister = false
|
|
@State private var name = ""
|
|
@State private var email = ""
|
|
@State private var password = ""
|
|
|
|
private var isLoading: Bool {
|
|
if case .loading = authService.state { return true }
|
|
return false
|
|
}
|
|
|
|
private var errorMessage: String? {
|
|
if case .error(let msg) = authService.state { return msg }
|
|
return nil
|
|
}
|
|
|
|
private var isValidEmail: Bool {
|
|
let p = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
|
|
return email.range(of: p, options: .regularExpression) != nil
|
|
}
|
|
|
|
private var isValidPassword: Bool {
|
|
password.count >= 8
|
|
&& password.rangeOfCharacter(from: .uppercaseLetters) != nil
|
|
&& password.rangeOfCharacter(from: .lowercaseLetters) != nil
|
|
&& password.rangeOfCharacter(from: .decimalDigits) != nil
|
|
}
|
|
|
|
private var isValid: Bool {
|
|
isValidEmail && isValidPassword && (!isRegister || !name.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
CMColors.bg.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
Spacer(minLength: 60)
|
|
|
|
Image(systemName: "clock.badge.checkmark")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(CMColors.accent)
|
|
|
|
Text("ChronoMind")
|
|
.font(CMFonts.display(size: 28))
|
|
.foregroundColor(CMColors.text)
|
|
|
|
Text(isRegister ? "Create your account" : "Sign in to your account")
|
|
.font(CMFonts.body(size: 15))
|
|
.foregroundColor(CMColors.textSecondary)
|
|
|
|
VStack(spacing: 14) {
|
|
if isRegister {
|
|
TextField("Full Name", text: $name)
|
|
.textContentType(.name)
|
|
.autocapitalization(.words)
|
|
.padding(14)
|
|
.background(CMColors.surface)
|
|
.cornerRadius(CMRadius.md)
|
|
.foregroundColor(CMColors.text)
|
|
}
|
|
|
|
TextField("Email", text: $email)
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
.padding(14)
|
|
.background(CMColors.surface)
|
|
.cornerRadius(CMRadius.md)
|
|
.foregroundColor(CMColors.text)
|
|
|
|
SecureField("Password", text: $password)
|
|
.textContentType(isRegister ? .newPassword : .password)
|
|
.padding(14)
|
|
.background(CMColors.surface)
|
|
.cornerRadius(CMRadius.md)
|
|
.foregroundColor(CMColors.text)
|
|
|
|
if !password.isEmpty && isRegister && !isValidPassword {
|
|
Text("Password needs: 8+ chars, uppercase, lowercase, digit")
|
|
.font(.caption)
|
|
.foregroundColor(CMColors.warning)
|
|
}
|
|
}
|
|
.padding(.horizontal, 24)
|
|
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.font(.caption)
|
|
.foregroundColor(CMColors.error)
|
|
.padding(.horizontal, 24)
|
|
}
|
|
|
|
Button {
|
|
Task {
|
|
if isRegister {
|
|
await authService.register(name: name, email: email, password: password)
|
|
} else {
|
|
await authService.login(email: email, password: password)
|
|
}
|
|
}
|
|
} label: {
|
|
if isLoading {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 48)
|
|
} else {
|
|
Text(isRegister ? "Create Account" : "Sign In")
|
|
.fontWeight(.semibold)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 48)
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(CMColors.accent)
|
|
.disabled(!isValid || isLoading)
|
|
.padding(.horizontal, 24)
|
|
|
|
HStack {
|
|
Text(isRegister ? "Already have an account?" : "Don't have an account?")
|
|
.font(.caption)
|
|
.foregroundColor(CMColors.textSecondary)
|
|
Button(isRegister ? "Sign In" : "Register") {
|
|
withAnimation { isRegister.toggle() }
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(CMColors.accent)
|
|
}
|
|
|
|
Spacer(minLength: 40)
|
|
}
|
|
}
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
}
|
|
}
|