Files
suixinkan_uikit/suixinkan/UI/Login/LoginViewModel.swift
T

231 lines
7.4 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.
//
// LoginViewModel.swift
// suixinkan
//
import Foundation
/// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。
final class LoginViewModel {
private(set) var account = ""
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 {
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.session.lastLoginUsername {
account = lastUsername
}
isPrivacyChecked = AppStore.shared.session.privacyAgreementAccepted
notifyStateChange()
}
func updateAccount(_ value: String) {
account = value
notifyStateChange()
}
func updatePassword(_ value: String) {
password = value
notifyStateChange()
}
func togglePrivacyChecked() {
isPrivacyChecked.toggle()
notifyStateChange()
}
func updateEnableRegister(_ enabled: Bool) {
enableRegister = enabled
notifyStateChange()
}
/// 当输入包含 +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 {
return .privacyUnchecked
}
return nil
}
func login() {
if let error = validateForLogin() {
onShowMessage?(error.message)
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
}
let response = try await setUser(account, tempToken: payload.tempToken, authAPI: authAPI)
pendingAccountSelection = nil
notifyStateChange()
return response
}
/// 用户确认登录冷静期账号后继续换取正式 token;`set-user` 成功即由后端撤销原注销申请。
func confirmDeregistrationLogin(
_ confirmation: DeregistrationLoginConfirmation,
authAPI: AuthAPI
) async throws -> V9AuthResponse {
try await setUser(confirmation.account, tempToken: confirmation.tempToken, authAPI: authAPI)
}
private func setUser(
_ account: AccountSwitchAccount,
tempToken: String,
authAPI: AuthAPI
) async throws -> V9AuthResponse {
guard !tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 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: tempToken)
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
return response
}
/// 清空待选账号信息,通常在用户取消账号选择时调用。
func clearPendingAccountSelection() {
pendingAccountSelection = nil
notifyStateChange()
}
var onShowMessage: ((String) -> Void)?
var onLogin: (() -> Void)?
var onLoginByCode: (() -> Void)?
var onRegister: (() -> Void)?
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 {
if account.requiresDeregistrationConfirmation {
return .needsDeregistrationConfirmation(
DeregistrationLoginConfirmation(tempToken: token, account: account)
)
}
let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token)
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
return .completed(finalResponse, account)
}
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?()
}
}