369 lines
13 KiB
Swift
369 lines
13 KiB
Swift
//
|
||
// LoginViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 登录页,UI 严格对齐 Android `LoginScreen`。
|
||
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: "请输入账号")
|
||
private let passwordField = PasswordInputField()
|
||
private let agreementRow = AgreementRowView()
|
||
private let loginButton = AppButton(title: "登录", style: .primary)
|
||
private let loginByCodeButton = AppButton(title: "验证码登录", style: .secondary)
|
||
private let registerContainer = UIStackView()
|
||
private let registerPrefixLabel = UILabel()
|
||
private let registerButton = UIButton(type: .system)
|
||
|
||
private weak var accountSelectionController: AccountSelectionViewController?
|
||
|
||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||
.lightContent
|
||
}
|
||
|
||
override func setupNavigationBar() {
|
||
navigationController?.setNavigationBarHidden(true, animated: false)
|
||
}
|
||
|
||
override func setupUI() {
|
||
view.backgroundColor = .black
|
||
|
||
backgroundImageView.image = UIImage(named: "img_login_bg")
|
||
backgroundImageView.contentMode = .scaleAspectFill
|
||
backgroundImageView.clipsToBounds = true
|
||
|
||
welcomeLabel.text = "欢迎使用\n随心瞰商家版"
|
||
welcomeLabel.numberOfLines = 2
|
||
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
|
||
|
||
topFormStack.axis = .vertical
|
||
topFormStack.spacing = 0
|
||
topFormStack.alignment = .fill
|
||
topFormStack.distribution = .fill
|
||
|
||
loginButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside)
|
||
loginByCodeButton.addTarget(self, action: #selector(loginByCodeButtonTapped), for: .touchUpInside)
|
||
|
||
registerContainer.axis = .horizontal
|
||
registerContainer.alignment = .center
|
||
registerContainer.spacing = 0
|
||
|
||
registerPrefixLabel.text = "没有账号?"
|
||
registerPrefixLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||
registerPrefixLabel.textColor = AppColor.text333
|
||
|
||
registerButton.setTitle("去注册", for: .normal)
|
||
registerButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||
registerButton.setTitleColor(AppColor.primary, for: .normal)
|
||
registerButton.addTarget(self, action: #selector(registerButtonTapped), for: .touchUpInside)
|
||
|
||
registerContainer.addArrangedSubview(registerPrefixLabel)
|
||
registerContainer.addArrangedSubview(registerButton)
|
||
|
||
view.addSubview(backgroundImageView)
|
||
view.addSubview(welcomeLabel)
|
||
view.addSubview(formCardShadowView)
|
||
formCardShadowView.addSubview(formCardView)
|
||
formCardView.addSubview(topFormStack)
|
||
formCardView.addSubview(registerContainer)
|
||
|
||
topFormStack.addArrangedSubview(accountField)
|
||
topFormStack.setCustomSpacing(8, after: accountField)
|
||
topFormStack.addArrangedSubview(passwordField)
|
||
topFormStack.setCustomSpacing(24, after: passwordField)
|
||
topFormStack.addArrangedSubview(agreementRow)
|
||
topFormStack.setCustomSpacing(16, after: agreementRow)
|
||
topFormStack.addArrangedSubview(loginButton)
|
||
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()
|
||
}
|
||
|
||
override func setupConstraints() {
|
||
backgroundImageView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
|
||
welcomeLabel.snp.makeConstraints { make in
|
||
make.top.equalTo(view.safeAreaLayoutGuide).offset(48)
|
||
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24)
|
||
}
|
||
|
||
formCardShadowView.snp.makeConstraints { make in
|
||
make.top.equalTo(welcomeLabel.snp.bottom).offset(48)
|
||
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24)
|
||
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-24)
|
||
}
|
||
|
||
formCardView.snp.makeConstraints { make in
|
||
make.edges.equalToSuperview()
|
||
}
|
||
|
||
registerContainer.snp.makeConstraints { make in
|
||
make.centerX.equalToSuperview()
|
||
}
|
||
|
||
applyCardLayoutConstraints(showRegisterSection: false)
|
||
}
|
||
|
||
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)
|
||
}
|
||
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)
|
||
}
|
||
passwordField.onReturnKey = { [weak self] in
|
||
self?.view.endEditing(true)
|
||
self?.viewModel.login()
|
||
}
|
||
|
||
agreementRow.onCheckedChange = { [weak self] in
|
||
self?.viewModel.togglePrivacyChecked()
|
||
}
|
||
agreementRow.onUserAgreementTap = { [weak self] in
|
||
self?.viewModel.onUserAgreement?()
|
||
}
|
||
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)
|
||
Task { await viewModel.loadAppConfig(authAPI: authAPI) }
|
||
}
|
||
|
||
private func bindViewModel() {
|
||
viewModel.onStateChange = { [weak self] in
|
||
self?.updateUI()
|
||
}
|
||
viewModel.onShowMessage = { [weak self] message in
|
||
self?.showToast(message)
|
||
}
|
||
viewModel.onLogin = { [weak self] in
|
||
self?.performLogin()
|
||
}
|
||
viewModel.onLoginByCode = { [weak self] in
|
||
self?.showToast("验证码登录待接入")
|
||
}
|
||
viewModel.onRegister = { [weak self] in
|
||
self?.showToast("注册待接入")
|
||
}
|
||
viewModel.onUserAgreement = { [weak self] in
|
||
self?.showToast("用户协议待接入")
|
||
}
|
||
viewModel.onPrivacyPolicy = { [weak self] in
|
||
self?.showToast("隐私政策待接入")
|
||
}
|
||
}
|
||
|
||
private func updateUI() {
|
||
agreementRow.isChecked = viewModel.isPrivacyChecked
|
||
loginButton.isEnabled = viewModel.isLoginEnabled && !viewModel.isLoading
|
||
loginButton.alpha = viewModel.isLoading ? 0.7 : 1
|
||
|
||
let showRegisterSection = viewModel.enableRegister
|
||
loginByCodeButton.isHidden = !showRegisterSection
|
||
registerContainer.isHidden = !showRegisterSection
|
||
applyCardLayoutConstraints(showRegisterSection: showRegisterSection)
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
/// 卡片高度随内容自适应,底部与屏幕 safeArea 至少保留 24pt 间距。
|
||
private func applyCardLayoutConstraints(showRegisterSection: Bool) {
|
||
if showRegisterSection {
|
||
topFormStack.snp.remakeConstraints { make in
|
||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||
}
|
||
registerContainer.snp.remakeConstraints { make in
|
||
make.top.equalTo(topFormStack.snp.bottom).offset(16)
|
||
make.centerX.equalToSuperview()
|
||
make.bottom.equalToSuperview().inset(16)
|
||
}
|
||
} else {
|
||
registerContainer.snp.remakeConstraints { make in
|
||
make.centerX.equalToSuperview()
|
||
}
|
||
topFormStack.snp.remakeConstraints { make in
|
||
make.edges.equalToSuperview().inset(16)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func performLogin() {
|
||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||
accountField.text = viewModel.normalizedUsername
|
||
|
||
Task {
|
||
showLoading()
|
||
defer { hideLoading() }
|
||
|
||
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()
|
||
}
|
||
|
||
@objc private func loginByCodeButtonTapped() {
|
||
view.endEditing(true)
|
||
viewModel.onLoginByCode?()
|
||
}
|
||
|
||
@objc private func registerButtonTapped() {
|
||
view.endEditing(true)
|
||
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
|
||
}
|
||
}
|