Files
suixinkan_ios_uikit/suixinkan_ios/Features/Auth/ViewModels/LoginViewModel.swift
2026-06-26 14:33:31 +08:00

213 lines
6.9 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
//
// Created by Codex on 2026/6/18.
//
import Foundation
///
enum LoginField: Hashable {
case username
case password
}
///
enum LoginValidationError: Equatable {
case invalidPhone
case emptyPassword
case privacyUnchecked
var message: String {
switch self {
case .invalidPhone:
"请输入有效的手机号码"
case .emptyPassword:
"请输入密码"
case .privacyUnchecked:
"请先阅读并同意相关协议"
}
}
var focusField: LoginField? {
switch self {
case .invalidPhone:
.username
case .emptyPassword:
.password
case .privacyUnchecked:
nil
}
}
}
///
enum LoginResolution {
case completed(V9AuthResponse)
case needsAccountSelection(AccountSelectionPayload)
}
/// token ID
enum LoginFlowError: LocalizedError {
case missingToken
case noAvailableAccount
case invalidAccount
var errorDescription: String? {
switch self {
case .missingToken:
"登录响应缺少 token"
case .noAvailableAccount:
"当前账号没有可用的景区账号或门店账号,请联系管理员"
case .invalidAccount:
"账号信息异常,请重新选择账号"
}
}
}
@MainActor
/// ViewModel
final class LoginViewModel {
var onChange: (() -> Void)?
var username = "" { didSet { onChange?() } }
var password = "" { didSet { onChange?() } }
var privacyChecked = false { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var isSelectingAccount = false { didSet { onChange?() } }
var showsPassword = false { didSet { onChange?() } }
var showsAgreementSheet = false { didSet { onChange?() } }
var pendingAccountSelection: AccountSelectionPayload? { didSet { onChange?() } }
var canSubmit: Bool {
isValidPhone && !trimmedPassword.isEmpty
}
var trimmedPassword: String {
password.trimmingCharacters(in: .whitespacesAndNewlines)
}
var normalizedUsername: String {
normalizedPhoneNumber(username)
}
var isValidPhone: Bool {
normalizedUsername.count == 11 && normalizedUsername.first == "1"
}
///
func applyPreferences(_ preferences: LoginPreferences) {
if username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let lastUsername = preferences.lastUsername {
username = lastUsername
}
privacyChecked = preferences.privacyAgreementAccepted
}
///
func validateForLogin() -> LoginValidationError? {
guard isValidPhone else {
return .invalidPhone
}
guard !trimmedPassword.isEmpty else {
return .emptyPassword
}
guard privacyChecked else {
return .privacyUnchecked
}
return nil
}
///
func acceptAgreement() {
privacyChecked = true
showsAgreementSheet = false
}
/// +86 11
func normalizeUsernameCountryCodeIfNeeded() {
let digits = username.filter(\.isNumber)
let normalizedPhone = normalizedPhoneNumber(username)
guard normalizedPhone != digits, normalizedPhone.count == 11 else { return }
username = normalizedPhone
}
///
func login(authAPI: AuthAPI) async throws -> LoginResolution {
guard !isLoading else {
throw CancellationError()
}
isLoading = true
defer { isLoading = false }
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
defer { isSelectingAccount = false }
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken)
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
pendingAccountSelection = nil
return response
}
///
func clearPendingAccountSelection() {
pendingAccountSelection = nil
}
/// set-user
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
return .needsAccountSelection(payload)
}
/// 86
private func normalizedPhoneNumber(_ value: String) -> String {
var digits = value.filter(\.isNumber)
if digits.hasPrefix("86"), digits.count == 13 {
digits.removeFirst(2)
}
return digits
}
}