417 lines
15 KiB
Swift
417 lines
15 KiB
Swift
//
|
|
// LoginViewController.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import SafariServices
|
|
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(50)
|
|
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24)
|
|
}
|
|
|
|
formCardShadowView.snp.makeConstraints { make in
|
|
make.top.equalTo(welcomeLabel.snp.bottom).offset(50)
|
|
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?.openAgreementURL(AppAgreementURLs.userAgreement)
|
|
}
|
|
viewModel.onPrivacyPolicy = { [weak self] in
|
|
self?.openAgreementURL(AppAgreementURLs.privacyPolicy)
|
|
}
|
|
}
|
|
|
|
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)
|
|
handleLoginResolution(resolution)
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
showToast(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleLoginResolution(_ resolution: LoginResolution) {
|
|
switch resolution {
|
|
case let .completed(response, account):
|
|
completeLogin(with: response, account: account)
|
|
case .needsAccountSelection:
|
|
break
|
|
case let .accountDeletionPending(response, request):
|
|
presentAccountDeletionRecovery(response: response, request: request)
|
|
}
|
|
}
|
|
|
|
private func presentAccountDeletionRecovery(
|
|
response: V9AuthResponse,
|
|
request: AccountDeletionRequest
|
|
) {
|
|
let deletionTime = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt)
|
|
let alert = UIAlertController(
|
|
title: "账号正在注销中",
|
|
message: "账号将在 \(deletionTime) 永久注销。继续登录将取消本次注销申请。",
|
|
preferredStyle: .alert
|
|
)
|
|
alert.addAction(UIAlertAction(title: "暂不登录", style: .cancel))
|
|
alert.addAction(UIAlertAction(title: "恢复账号并登录", style: .default) { [weak self] _ in
|
|
self?.resumeLoginAfterCancelingDeletion(response: response)
|
|
})
|
|
present(alert, animated: true)
|
|
}
|
|
|
|
private func resumeLoginAfterCancelingDeletion(response: V9AuthResponse) {
|
|
Task {
|
|
showLoading()
|
|
defer { hideLoading() }
|
|
do {
|
|
let resolution = try await viewModel.resumeLoginAfterCancelingDeletion(
|
|
verifiedResponse: response,
|
|
authAPI: authAPI
|
|
)
|
|
handleLoginResolution(resolution)
|
|
} 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, account: account)
|
|
accountSelectionController = nil
|
|
dismiss(animated: true)
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
showToast(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func completeLogin(with response: V9AuthResponse, account: AccountSwitchAccount) {
|
|
AuthSessionHelper.completeLogin(
|
|
with: response,
|
|
username: viewModel.normalizedUsername,
|
|
privacyAgreementAccepted: viewModel.isPrivacyChecked,
|
|
selectedAccount: account
|
|
)
|
|
}
|
|
|
|
@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?()
|
|
}
|
|
|
|
private func openAgreementURL(_ url: URL) {
|
|
let controller = SFSafariViewController(url: url)
|
|
present(controller, animated: true)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|