添加网络层并接入 v9 登录流程及单元测试

This commit is contained in:
2026-07-06 15:33:34 +08:00
parent 31ffec0f2b
commit d424928243
21 changed files with 2387 additions and 18 deletions

View File

@ -18,6 +18,7 @@ final class LoginTextField: UIView {
var onTextChange: ((String) -> Void)?
var onReturnKey: (() -> Void)?
var onEditingDidEnd: (() -> Void)?
init(placeholder: String) {
super.init(frame: .zero)
@ -55,6 +56,7 @@ final class LoginTextField: UIView {
textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes)
textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
textField.addTarget(self, action: #selector(editingDidEnd), for: .editingDidEnd)
addSubview(textField)
}
@ -72,6 +74,10 @@ final class LoginTextField: UIView {
@objc private func textDidChange() {
onTextChange?(text)
}
@objc private func editingDidEnd() {
onEditingDidEnd?()
}
}
extension LoginTextField: UITextFieldDelegate {

View File

@ -0,0 +1,318 @@
//
// AccountSelectionViewController.swift
// suixinkan
//
import SnapKit
import UIKit
private typealias AccountSelectionSection = Int
private typealias AccountSelectionItem = String
/// /
final class AccountSelectionViewController: UIViewController, UITableViewDelegate {
private let payload: AccountSelectionPayload
private var isLoading: Bool
private let onCancel: () -> Void
private let onConfirm: (AccountSwitchAccount) -> Void
private var selectedAccountId: String?
private let tableView = UITableView(frame: .zero, style: .plain)
private var tableDataSource: UITableViewDiffableDataSource<AccountSelectionSection, AccountSelectionItem>!
private let confirmButton = UIButton(type: .system)
private let bottomBar = UIView()
init(
payload: AccountSelectionPayload,
isLoading: Bool,
onCancel: @escaping () -> Void,
onConfirm: @escaping (AccountSwitchAccount) -> Void
) {
self.payload = payload
self.isLoading = isLoading
self.onCancel = onCancel
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = "选择账号"
view.backgroundColor = UIColor(hex: 0xF5F7FB)
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "取消",
style: .plain,
target: self,
action: #selector(cancelTapped)
)
isModalInPresentation = isLoading
selectedAccountId = payload.accounts.first?.id
configureTableView()
configureTableDataSource()
configureBottomBar()
}
/// Loading
func updateLoading(_ loading: Bool) {
isLoading = loading
isModalInPresentation = loading
confirmButton.isEnabled = canConfirm
confirmButton.backgroundColor = canConfirm ? AppColor.primary : AppColor.buttonDisabled
}
private var selectedAccount: AccountSwitchAccount? {
payload.accounts.first { $0.id == selectedAccountId }
}
private var canConfirm: Bool {
selectedAccount != nil && !isLoading
}
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource<AccountSelectionSection, AccountSelectionItem>(
tableView: tableView
) { [weak self] tableView, indexPath, item in
guard let self,
let cell = tableView.dequeueReusableCell(
withIdentifier: AccountSelectionCell.reuseIdentifier,
for: indexPath
) as? AccountSelectionCell,
let account = self.payload.accounts.first(where: { $0.id == item }) else {
return UITableViewCell()
}
cell.configure(account: account, selected: account.id == self.selectedAccountId)
return cell
}
applyTableSnapshot(animated: false)
}
private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot<AccountSelectionSection, AccountSelectionItem> {
var snapshot = NSDiffableDataSourceSnapshot<AccountSelectionSection, AccountSelectionItem>()
snapshot.appendSections([0])
snapshot.appendItems(payload.accounts.map(\.id), toSection: 0)
return snapshot
}
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildTableSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
private func configureTableView() {
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.delegate = self
tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier)
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(90)
}
}
private func configureBottomBar() {
bottomBar.backgroundColor = .white
let divider = UIView()
divider.backgroundColor = UIColor.separator
confirmButton.setTitle("进入系统", for: .normal)
confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.layer.cornerRadius = 8
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
updateLoading(isLoading)
view.addSubview(bottomBar)
bottomBar.addSubview(divider)
bottomBar.addSubview(confirmButton)
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
divider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
confirmButton.snp.makeConstraints { make in
make.top.equalTo(divider.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14)
make.height.equalTo(50)
}
}
@objc private func cancelTapped() {
onCancel()
dismiss(animated: true)
}
@objc private func confirmTapped() {
guard let selectedAccount else { return }
onConfirm(selectedAccount)
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return }
selectedAccountId = item
applyTableSnapshot(animated: false, reconfigure: true)
confirmButton.isEnabled = canConfirm
confirmButton.backgroundColor = canConfirm ? AppColor.primary : AppColor.buttonDisabled
}
}
/// Cell
private final class AccountSelectionCell: UITableViewCell {
static let reuseIdentifier = "AccountSelectionCell"
private let avatarView = UIView()
private let avatarLabel = UILabel()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let phoneLabel = UILabel()
private let typeTag = UILabel()
private let currentTag = UILabel()
private let checkmark = UIImageView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 8
contentView.layer.masksToBounds = true
avatarView.layer.cornerRadius = 25
avatarLabel.font = .systemFont(ofSize: 14, weight: .semibold)
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
titleLabel.textColor = AppColor.text333
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.text666
phoneLabel.font = .systemFont(ofSize: 12)
phoneLabel.textColor = UIColor(hex: 0x9AA1AA)
typeTag.font = .systemFont(ofSize: 11, weight: .semibold)
typeTag.textAlignment = .center
typeTag.layer.cornerRadius = 4
typeTag.clipsToBounds = true
currentTag.font = .systemFont(ofSize: 11, weight: .semibold)
currentTag.textAlignment = .center
currentTag.text = "当前"
currentTag.textColor = AppColor.primary
currentTag.backgroundColor = AppColor.secondaryButtonBackground
currentTag.layer.cornerRadius = 4
currentTag.clipsToBounds = true
currentTag.isHidden = true
checkmark.contentMode = .scaleAspectFit
contentView.addSubview(avatarView)
avatarView.addSubview(avatarLabel)
contentView.addSubview(titleLabel)
contentView.addSubview(subtitleLabel)
contentView.addSubview(phoneLabel)
contentView.addSubview(typeTag)
contentView.addSubview(currentTag)
contentView.addSubview(checkmark)
avatarView.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
make.width.height.equalTo(50)
}
avatarLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().inset(16)
make.leading.equalTo(avatarView.snp.trailing).offset(12)
make.trailing.lessThanOrEqualTo(checkmark.snp.leading).offset(-8)
}
currentTag.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(6)
make.centerY.equalTo(titleLabel)
make.height.equalTo(18)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.equalTo(titleLabel)
make.trailing.equalTo(checkmark.snp.leading).offset(-8)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(4)
make.leading.equalTo(titleLabel)
make.bottom.equalToSuperview().inset(16)
}
typeTag.snp.makeConstraints { make in
make.trailing.equalTo(checkmark.snp.leading).offset(-8)
make.top.equalTo(titleLabel)
make.height.equalTo(18)
}
checkmark.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
make.width.height.equalTo(22)
}
}
required init?(coder: NSCoder) {
nil
}
func configure(account: AccountSwitchAccount, selected: Bool) {
titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title
subtitleLabel.text = account.subtitle
subtitleLabel.isHidden = account.subtitle.isEmpty
phoneLabel.text = account.phone
phoneLabel.isHidden = account.phone.isEmpty
currentTag.isHidden = !account.isCurrent
if account.isStoreUser {
avatarView.backgroundColor = UIColor(hex: 0xE8F8F1)
avatarLabel.text = ""
avatarLabel.textColor = UIColor(hex: 0x0F9F6E)
typeTag.text = " 门店 "
typeTag.textColor = UIColor(hex: 0x0F9F6E)
typeTag.backgroundColor = UIColor(hex: 0xE8F8F1)
} else {
avatarView.backgroundColor = UIColor(hex: 0xF3ECFF)
avatarLabel.text = ""
avatarLabel.textColor = UIColor(hex: 0x7C3AED)
typeTag.text = " 景区 "
typeTag.textColor = UIColor(hex: 0x7C3AED)
typeTag.backgroundColor = UIColor(hex: 0xF3ECFF)
}
let symbolName = selected ? "checkmark.circle.fill" : "circle"
checkmark.image = UIImage(systemName: symbolName)
checkmark.tintColor = selected ? AppColor.primary : UIColor(hex: 0xB6BECA)
contentView.layer.borderWidth = selected ? 1.4 : 1
contentView.layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xE6ECF4)).cgColor
}
}

View File

@ -10,9 +10,11 @@ import UIKit
final class LoginViewController: BaseViewController {
private let viewModel = LoginViewModel()
private let authAPI = NetworkServices.shared.authAPI
private let backgroundImageView = UIImageView()
private let welcomeLabel = UILabel()
private let formCardShadowView = UIView()
private let formCardView = UIView()
private let topFormStack = UIStackView()
private let accountField = LoginTextField(placeholder: "请输入账号")
@ -24,7 +26,7 @@ final class LoginViewController: BaseViewController {
private let registerPrefixLabel = UILabel()
private let registerButton = UIButton(type: .system)
private var hasPlayedEntranceAnimation = false
private weak var accountSelectionController: AccountSelectionViewController?
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
@ -46,6 +48,12 @@ final class LoginViewController: BaseViewController {
welcomeLabel.font = .systemFont(ofSize: 28, weight: .semibold)
welcomeLabel.textColor = .white
formCardShadowView.backgroundColor = .clear
formCardShadowView.layer.shadowColor = UIColor.black.cgColor
formCardShadowView.layer.shadowOpacity = 0.14
formCardShadowView.layer.shadowOffset = CGSize(width: 0, height: 8)
formCardShadowView.layer.shadowRadius = 20
formCardView.backgroundColor = .white
formCardView.layer.cornerRadius = 16
formCardView.clipsToBounds = true
@ -75,7 +83,8 @@ final class LoginViewController: BaseViewController {
view.addSubview(backgroundImageView)
view.addSubview(welcomeLabel)
view.addSubview(formCardView)
view.addSubview(formCardShadowView)
formCardShadowView.addSubview(formCardView)
formCardView.addSubview(topFormStack)
formCardView.addSubview(registerContainer)
@ -89,6 +98,12 @@ final class LoginViewController: BaseViewController {
topFormStack.setCustomSpacing(8, after: loginButton)
topFormStack.addArrangedSubview(loginByCodeButton)
viewModel.applyStoredPreferences()
if let username = viewModel.normalizedUsername.nonEmpty ?? viewModel.account.nonEmpty {
accountField.text = username
}
agreementRow.isChecked = viewModel.isPrivacyChecked
bindViewModel()
updateUI()
}
@ -103,12 +118,16 @@ final class LoginViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(24)
}
formCardView.snp.makeConstraints { make in
formCardShadowView.snp.makeConstraints { make in
make.top.equalTo(welcomeLabel.snp.bottom).offset(48)
make.leading.trailing.equalToSuperview().inset(24)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-24)
}
formCardView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
topFormStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().inset(16)
@ -120,6 +139,14 @@ final class LoginViewController: BaseViewController {
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
formCardShadowView.layer.shadowPath = UIBezierPath(
roundedRect: formCardShadowView.bounds,
cornerRadius: 16
).cgPath
}
override func bindActions() {
accountField.onTextChange = { [weak self] text in
self?.viewModel.updateAccount(text)
@ -127,6 +154,12 @@ final class LoginViewController: BaseViewController {
accountField.onReturnKey = { [weak self] in
self?.passwordField.becomeFirstResponder()
}
accountField.onEditingDidEnd = { [weak self] in
self?.viewModel.normalizeUsernameCountryCodeIfNeeded()
if let username = self?.viewModel.normalizedUsername {
self?.accountField.text = username
}
}
passwordField.onTextChange = { [weak self] text in
self?.viewModel.updatePassword(text)
@ -145,11 +178,24 @@ final class LoginViewController: BaseViewController {
agreementRow.onPrivacyPolicyTap = { [weak self] in
self?.viewModel.onPrivacyPolicy?()
}
setupDismissKeyboardGesture()
}
private func setupDismissKeyboardGesture() {
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
tap.cancelsTouchesInView = false
tap.delegate = self
view.addGestureRecognizer(tap)
}
@objc private func dismissKeyboard() {
view.endEditing(true)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
playEntranceAnimationIfNeeded()
Task { await viewModel.loadAppConfig(authAPI: authAPI) }
}
private func bindViewModel() {
@ -160,7 +206,7 @@ final class LoginViewController: BaseViewController {
self?.showToast(message)
}
viewModel.onLogin = { [weak self] in
self?.showToast("登录功能待接入")
self?.performLogin()
}
viewModel.onLoginByCode = { [weak self] in
self?.showToast("验证码登录待接入")
@ -178,7 +224,8 @@ final class LoginViewController: BaseViewController {
private func updateUI() {
agreementRow.isChecked = viewModel.isPrivacyChecked
loginButton.isEnabled = viewModel.isLoginEnabled
loginButton.isEnabled = viewModel.isLoginEnabled && !viewModel.isLoading
loginButton.alpha = viewModel.isLoading ? 0.7 : 1
let showRegisterSection = viewModel.enableRegister
loginByCodeButton.isHidden = !showRegisterSection
@ -192,21 +239,87 @@ final class LoginViewController: BaseViewController {
make.bottom.equalToSuperview().inset(16)
}
}
if let payload = viewModel.pendingAccountSelection {
if let controller = accountSelectionController {
controller.updateLoading(viewModel.isSelectingAccount)
} else if presentedViewController == nil {
presentAccountSelection(payload)
}
} else if accountSelectionController != nil {
dismiss(animated: true)
accountSelectionController = nil
}
}
private func playEntranceAnimationIfNeeded() {
guard !hasPlayedEntranceAnimation else { return }
hasPlayedEntranceAnimation = true
private func performLogin() {
viewModel.normalizeUsernameCountryCodeIfNeeded()
accountField.text = viewModel.normalizedUsername
formCardView.transform = CGAffineTransform(translationX: 0, y: view.bounds.height)
welcomeLabel.alpha = 0
Task {
showLoading()
defer { hideLoading() }
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) {
self.formCardView.transform = .identity
self.welcomeLabel.alpha = 1
do {
let resolution = try await viewModel.login(authAPI: authAPI)
switch resolution {
case let .completed(response):
completeLogin(with: response)
case .needsAccountSelection:
break
}
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func presentAccountSelection(_ payload: AccountSelectionPayload) {
let controller = AccountSelectionViewController(
payload: payload,
isLoading: viewModel.isSelectingAccount,
onCancel: { [weak self] in
self?.viewModel.clearPendingAccountSelection()
self?.accountSelectionController = nil
},
onConfirm: { [weak self] account in
self?.selectAccount(account)
}
)
accountSelectionController = controller
let navigation = UINavigationController(rootViewController: controller)
navigation.modalPresentationStyle = .formSheet
present(navigation, animated: true)
}
private func selectAccount(_ account: AccountSwitchAccount) {
Task {
accountSelectionController?.updateLoading(true)
defer { accountSelectionController?.updateLoading(false) }
do {
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
completeLogin(with: response)
accountSelectionController = nil
dismiss(animated: true)
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func completeLogin(with response: V9AuthResponse) {
AuthSessionHelper.completeLogin(
with: response,
username: viewModel.normalizedUsername,
privacyAgreementAccepted: viewModel.isPrivacyChecked
)
}
@objc private func loginButtonTapped() {
view.endEditing(true)
viewModel.login()
@ -222,3 +335,24 @@ final class LoginViewController: BaseViewController {
viewModel.onRegister?()
}
}
extension LoginViewController: UIGestureRecognizerDelegate {
///
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
var candidate: UIView? = touch.view
while let view = candidate {
if view is UITextField || view is UIButton {
return false
}
candidate = view.superview
}
return true
}
}
private extension String {
var nonEmpty: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

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?()
}