import SwiftUI import Security // MARK: - Keychain enum Keychain { @discardableResult static func save(key: String, value: String) -> Bool { guard let data = value.data(using: .utf8) else { return false } let deleteQuery: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key ] SecItemDelete(deleteQuery as CFDictionary) let addQuery: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data, kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ] return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess } static func load(key: String) -> String? { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: kCFBooleanTrue!, kSecMatchLimit: kSecMatchLimitOne ] var result: AnyObject? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } @discardableResult static func delete(key: String) -> Bool { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key ] return SecItemDelete(query as CFDictionary) == errSecSuccess } } // MARK: - AuthView struct AuthView: View { var onAuthenticated: (String) -> Void @State private var isLoginMode = true @State private var email = "" @State private var password = "" @State private var displayName = "" @State private var nativeLanguage = "EN" @State private var isLoading = false @State private var errorMessage = "" private let nativeLangs = ["EN", "FR", "ES", "PT", "IT", "DE", "ZH", "JA"] // Design tokens — identical to ContentView let accentColor = Color(hex: "#f59e0b") let bgColor = Color(hex: "#080d14") let surfaceColor = Color(hex: "#0d1421") let borderColor = Color(hex: "#1a2535") let textColor = Color(hex: "#cbd5e1") let mutedColor = Color(hex: "#475569") let errorColor = Color(hex: "#ef4444") var body: some View { ZStack { bgColor.ignoresSafeArea() VStack(spacing: 0) { // ── Wordmark ────────────────────────────────────────── VStack(spacing: 6) { Text("MIMIO") .font(.system(size: 26, weight: .bold, design: .monospaced)) .foregroundColor(accentColor) .tracking(8) Text("hear yourself, clearer") .font(.system(size: 11, design: .monospaced)) .foregroundColor(mutedColor) .tracking(2) } .padding(.top, 72) .padding(.bottom, 48) VStack(spacing: 14) { // ── Mode toggle ─────────────────────────────────── HStack(spacing: 0) { modeToggleButton("LOGIN", selected: isLoginMode) { switchMode(true) } modeToggleButton("REGISTER", selected: !isLoginMode) { switchMode(false) } } .background(surfaceColor) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(borderColor, lineWidth: 1) ) .cornerRadius(8) // ── Display name + native language (register only) ── if !isLoginMode { authField("Display name", text: $displayName, keyboard: .default) .transition(.opacity.combined(with: .move(edge: .top))) VStack(alignment: .leading, spacing: 8) { Text("I SPEAK…") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(mutedColor) .tracking(2) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(nativeLangs, id: \.self) { lang in Button { nativeLanguage = lang } label: { Text(lang) .font(.system(size: 11, weight: .bold, design: .monospaced)) .tracking(1) .foregroundColor(nativeLanguage == lang ? bgColor : mutedColor) .padding(.horizontal, 13) .padding(.vertical, 6) .background(nativeLanguage == lang ? accentColor : Color.clear) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(nativeLanguage == lang ? accentColor : borderColor, lineWidth: 1) ) .cornerRadius(12) } } } } } .transition(.opacity.combined(with: .move(edge: .top))) } // ── Email ───────────────────────────────────────── authField("Email", text: $email, keyboard: .emailAddress) .autocorrectionDisabled() .textInputAutocapitalization(.never) // ── Password ────────────────────────────────────── SecureField("Password", text: $password) .font(.system(size: 14, design: .monospaced)) .foregroundColor(textColor) .padding(14) .background(surfaceColor) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(borderColor, lineWidth: 1) ) .cornerRadius(8) // ── Error ───────────────────────────────────────── if !errorMessage.isEmpty { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 10)) Text(errorMessage) .font(.system(size: 11, design: .monospaced)) } .foregroundColor(errorColor) .frame(maxWidth: .infinity, alignment: .leading) .transition(.opacity) } // ── Submit ──────────────────────────────────────── Button { submit() } label: { HStack(spacing: 8) { if isLoading { ProgressView() .tint(Color(hex: "#080d14")) .scaleEffect(0.8) } Text(isLoading ? (isLoginMode ? "Logging in..." : "Creating account...") : (isLoginMode ? "LOGIN" : "CREATE ACCOUNT")) .font(.system(size: 13, weight: .bold, design: .monospaced)) .tracking(2) } .foregroundColor(Color(hex: "#080d14")) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(isLoading ? accentColor.opacity(0.65) : accentColor) .cornerRadius(8) } .disabled(isLoading) .padding(.top, 4) } .padding(.horizontal, 24) .animation(.easeInOut(duration: 0.18), value: isLoginMode) .animation(.easeInOut(duration: 0.18), value: errorMessage) Spacer() // ── Footer ──────────────────────────────────────────── Text("v1.0 · mimio") .font(.system(size: 9, design: .monospaced)) .foregroundColor(mutedColor.opacity(0.5)) .tracking(2) .padding(.bottom, 32) } } } // MARK: - Sub-views @ViewBuilder private func modeToggleButton(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(.system(size: 11, weight: .bold, design: .monospaced)) .tracking(2) .foregroundColor(selected ? Color(hex: "#080d14") : mutedColor) .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(selected ? accentColor : Color.clear) .cornerRadius(7) .padding(2) } } @ViewBuilder private func authField( _ placeholder: String, text: Binding, keyboard: UIKeyboardType ) -> some View { TextField(placeholder, text: text) .font(.system(size: 14, design: .monospaced)) .foregroundColor(textColor) .keyboardType(keyboard) .padding(14) .background(surfaceColor) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(borderColor, lineWidth: 1) ) .cornerRadius(8) } // MARK: - Logic private func switchMode(_ login: Bool) { isLoginMode = login errorMessage = "" } private func submit() { errorMessage = "" // Basic client-side validation guard !email.trimmingCharacters(in: .whitespaces).isEmpty else { errorMessage = "Email is required." return } guard !password.isEmpty else { errorMessage = "Password is required." return } if !isLoginMode && displayName.trimmingCharacters(in: .whitespaces).isEmpty { errorMessage = "Display name is required." return } let endpoint = isLoginMode ? "login" : "register" guard let url = URL(string: "https://coach.cubecast.app/\(endpoint)") else { return } var body: [String: String] = [ "email": email.trimmingCharacters(in: .whitespaces), "password": password ] if !isLoginMode { body["name"] = displayName.trimmingCharacters(in: .whitespaces) body["native_language"] = nativeLanguage } guard let payload = try? JSONSerialization.data(withJSONObject: body) else { return } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = payload req.timeoutInterval = 15 isLoading = true URLSession.shared.dataTask(with: req) { data, response, error in DispatchQueue.main.async { isLoading = false if let error = error { errorMessage = error.localizedDescription return } guard let httpResponse = response as? HTTPURLResponse, let data = data else { errorMessage = "No response from server." return } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { errorMessage = "Unexpected server response." return } // Success path — expect { "token": "...", "user_id": ... } if (200...299).contains(httpResponse.statusCode), let token = json["token"] as? String { Keychain.save(key: "mimio_token", value: token) if let userId = json["user_id"] as? Int { UserDefaults.standard.set(userId, forKey: "mimio_user_id") } else if let userId = json["user_id"] as? String { UserDefaults.standard.set(userId, forKey: "mimio_user_id") } // Save native language — from picker on register, or from server on login if !isLoginMode { UserDefaults.standard.set(nativeLanguage, forKey: "mimio_native_language") } else if let lang = json["native_language"] as? String { UserDefaults.standard.set(lang, forKey: "mimio_native_language") } else { UserDefaults.standard.set(nativeLanguage, forKey: "mimio_native_language") } onAuthenticated(token) return } // Error path — try common error keys if let msg = json["error"] as? String, !msg.isEmpty { errorMessage = msg } else if let msg = json["message"] as? String, !msg.isEmpty { errorMessage = msg } else if let msg = json["detail"] as? String, !msg.isEmpty { errorMessage = msg } else { errorMessage = httpResponse.statusCode == 401 ? "Invalid email or password." : "Something went wrong (HTTP \(httpResponse.statusCode))." } } }.resume() } }