Add networking layer and wire up v9 login flow with unit tests.

Introduce APIClient/AuthAPI, complete login with multi-account selection, and polish the login card UI with shadow and keyboard dismiss.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 15:33:34 +08:00
parent b7d74905b7
commit 699f1ba7c4
21 changed files with 2387 additions and 18 deletions

View File

@ -12,13 +12,38 @@ final class LoginViewModel {
private(set) var password = ""
private(set) var isPrivacyChecked = false
private(set) var enableRegister = false
private(set) var isLoading = false
private(set) var isSelectingAccount = false
private(set) var pendingAccountSelection: AccountSelectionPayload?
var isLoginEnabled: Bool {
!account.isEmpty && !password.isEmpty
isValidPhone && !trimmedPassword.isEmpty
}
var trimmedPassword: String {
password.trimmingCharacters(in: .whitespacesAndNewlines)
}
var normalizedUsername: String {
normalizedPhoneNumber(account)
}
var isValidPhone: Bool {
normalizedUsername.count == 11 && normalizedUsername.first == "1"
}
var onStateChange: (() -> Void)?
///
func applyStoredPreferences() {
if account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let lastUsername = AppStore.shared.lastLoginUsername {
account = lastUsername
}
isPrivacyChecked = AppStore.shared.privacyAgreementAccepted
notifyStateChange()
}
func updateAccount(_ value: String) {
account = value
notifyStateChange()
@ -39,15 +64,101 @@ final class LoginViewModel {
notifyStateChange()
}
func login() {
/// +86 11
func normalizeUsernameCountryCodeIfNeeded() {
let digits = account.filter(\.isNumber)
let normalizedPhone = normalizedPhoneNumber(account)
guard normalizedPhone != digits, normalizedPhone.count == 11 else { return }
account = normalizedPhone
notifyStateChange()
}
///
func validateForLogin() -> LoginValidationError? {
guard isValidPhone else {
return .invalidPhone
}
guard !trimmedPassword.isEmpty else {
return .emptyPassword
}
guard isPrivacyChecked else {
onShowMessage?("请先阅读并同意用户协议与隐私政策")
return .privacyUnchecked
}
return nil
}
func login() {
if let error = validateForLogin() {
onShowMessage?(error.message)
return
}
guard isLoginEnabled else { return }
onLogin?()
}
/// App /
func loadAppConfig(authAPI: AuthAPI) async {
do {
let config = try await authAPI.getAppConfig()
updateEnableRegister(config.enableRegister)
} catch {
//
}
}
///
func login(authAPI: AuthAPI) async throws -> LoginResolution {
guard !isLoading else {
throw CancellationError()
}
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
let response = try await authAPI.login(
username: normalizedUsername,
password: trimmedPassword
)
return try await resolveLoginResponse(response, authAPI: authAPI)
}
/// set-user token
func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse {
guard let payload = pendingAccountSelection, payload.hasTempToken else {
throw LoginFlowError.missingToken
}
guard account.businessUserId > 0 else {
throw LoginFlowError.invalidAccount
}
guard !isSelectingAccount else {
throw CancellationError()
}
isSelectingAccount = true
notifyStateChange()
defer {
isSelectingAccount = false
notifyStateChange()
}
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken)
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
pendingAccountSelection = nil
notifyStateChange()
return response
}
///
func clearPendingAccountSelection() {
pendingAccountSelection = nil
notifyStateChange()
}
var onShowMessage: ((String) -> Void)?
var onLogin: (() -> Void)?
var onLoginByCode: (() -> Void)?
@ -55,6 +166,39 @@ final class LoginViewModel {
var onUserAgreement: (() -> Void)?
var onPrivacyPolicy: (() -> Void)?
private func resolveLoginResponse(_ response: V9AuthResponse, authAPI: AuthAPI) async throws -> LoginResolution {
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else {
throw LoginFlowError.missingToken
}
let accounts = response.accounts.filter { $0.businessUserId > 0 }
guard !accounts.isEmpty else {
throw LoginFlowError.noAvailableAccount
}
if accounts.count == 1, let account = accounts.first {
let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token)
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
return .completed(finalResponse)
}
let payload = AccountSelectionPayload(tempToken: token, accounts: accounts)
pendingAccountSelection = payload
notifyStateChange()
return .needsAccountSelection(payload)
}
private func normalizedPhoneNumber(_ value: String) -> String {
var digits = value.filter(\.isNumber)
if digits.hasPrefix("86"), digits.count == 13 {
digits.removeFirst(2)
}
return digits
}
private func notifyStateChange() {
onStateChange?()
}