Files
suixinkan_ios_uikit/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift
汉秋 d99a5b1bf8 Advance UIKit rewrite with AMap integration and core UI modules.
Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 15:16:12 +08:00

558 lines
22 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.

//
// LoginViewController.swift
// suixinkan
//
import SnapKit
import UIKit
@MainActor
/// /
final class LoginViewController: UIViewController {
private let services: AppServices
private let viewModel = LoginViewModel()
private let scrollView = UIScrollView()
private let contentView = UIView()
private let backgroundImageView = UIImageView(image: UIImage(named: "LoginBackground"))
private let titleLabel = UILabel()
private let cardView = UIView()
private let usernameField = LoginInputField(iconName: "person", placeholder: "请输入手机号", isSecure: false)
private let passwordField = LoginInputField(iconName: "lock", placeholder: "请输入密码", isSecure: true)
private let privacyButton = UIButton(type: .custom)
private let privacyLabel = UILabel()
private let userAgreementButton = UIButton(type: .system)
private let privacyPolicyButton = UIButton(type: .system)
private let loginButton = UIButton(type: .system)
///
init(services: AppServices) {
self.services = services
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0x0B1220)
configureViews()
bindViewModel()
viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences())
}
/// Views
private func configureViews() {
backgroundImageView.contentMode = .scaleAspectFill
backgroundImageView.clipsToBounds = true
titleLabel.text = "欢迎使用\n随心瞰商家版"
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.largeTitle, weight: .semibold)
titleLabel.textColor = .white
titleLabel.numberOfLines = 0
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppMetrics.CornerRadius.card
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.12
cardView.layer.shadowRadius = 18
cardView.layer.shadowOffset = CGSize(width: 0, height: 8)
usernameField.textField.keyboardType = .phonePad
usernameField.textField.textContentType = .telephoneNumber
usernameField.textField.autocorrectionType = .no
usernameField.textField.autocapitalizationType = .none
usernameField.textField.returnKeyType = .next
usernameField.textField.accessibilityIdentifier = "login.username"
usernameField.textField.delegate = self
usernameField.textField.addTarget(self, action: #selector(usernameChanged), for: .editingChanged)
passwordField.textField.autocorrectionType = .no
passwordField.textField.autocapitalizationType = .none
passwordField.textField.returnKeyType = .go
passwordField.textField.accessibilityIdentifier = "login.password"
passwordField.textField.delegate = self
passwordField.textField.addTarget(self, action: #selector(passwordChanged), for: .editingChanged)
passwordField.onToggleVisibility = { [weak self] in
self?.viewModel.showsPassword.toggle()
}
privacyButton.accessibilityIdentifier = "login.privacy"
privacyButton.addTarget(self, action: #selector(togglePrivacy), for: .touchUpInside)
configureAgreementText()
loginButton.setTitle("登录", for: .normal)
loginButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
loginButton.layer.cornerRadius = AppMetrics.CornerRadius.button
loginButton.accessibilityIdentifier = "login.submit"
loginButton.addTarget(self, action: #selector(loginTapped), for: .touchUpInside)
view.addSubview(backgroundImageView)
view.addSubview(scrollView)
scrollView.addSubview(contentView)
contentView.addSubview(titleLabel)
contentView.addSubview(cardView)
cardView.addSubview(usernameField)
cardView.addSubview(passwordField)
cardView.addSubview(privacyButton)
cardView.addSubview(privacyLabel)
cardView.addSubview(loginButton)
backgroundImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
contentView.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide)
make.width.equalTo(scrollView.frameLayoutGuide)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(contentView.safeAreaLayoutGuide).offset(72)
make.leading.trailing.equalToSuperview().inset(24)
}
titleLabel.accessibilityIdentifier = "login.title"
cardView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.xxLarge)
make.leading.trailing.equalToSuperview().inset(20)
make.bottom.equalToSuperview().inset(AppMetrics.Spacing.xxLarge)
}
usernameField.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.height.equalTo(AppMetrics.ControlSize.inputHeight)
}
passwordField.snp.makeConstraints { make in
make.top.equalTo(usernameField.snp.bottom).offset(AppMetrics.Spacing.xSmall)
make.leading.trailing.equalTo(usernameField)
make.height.equalTo(AppMetrics.ControlSize.inputHeight)
}
privacyButton.snp.makeConstraints { make in
make.top.equalTo(passwordField.snp.bottom).offset(AppMetrics.ControlSize.checkboxTapArea)
make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.width.height.equalTo(AppMetrics.ControlSize.checkboxTapArea)
}
privacyLabel.snp.makeConstraints { make in
make.leading.equalTo(privacyButton.snp.trailing)
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.top.equalTo(privacyButton).offset(AppMetrics.Spacing.xxSmall)
}
loginButton.snp.makeConstraints { make in
make.top.equalTo(privacyLabel.snp.bottom).offset(AppMetrics.Spacing.mediumLarge)
make.leading.trailing.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight)
}
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
tap.cancelsTouchesInView = false
view.addGestureRecognizer(tap)
}
/// AgreementText
private func configureAgreementText() {
privacyLabel.numberOfLines = 0
privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
userAgreementButton.setTitle("《用户协议》", for: .normal)
userAgreementButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
userAgreementButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal)
userAgreementButton.accessibilityIdentifier = "login.userAgreement"
userAgreementButton.addTarget(self, action: #selector(openUserAgreement), for: .touchUpInside)
privacyPolicyButton.setTitle("《隐私政策》", for: .normal)
privacyPolicyButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
privacyPolicyButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal)
privacyPolicyButton.accessibilityIdentifier = "login.privacyPolicy"
privacyPolicyButton.addTarget(self, action: #selector(openPrivacyPolicy), for: .touchUpInside)
let prefix = UILabel()
prefix.text = "已阅读并同意"
prefix.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
prefix.textColor = UIColor(hex: 0x666666)
let separator = UILabel()
separator.text = ""
separator.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
separator.textColor = UIColor(hex: 0x666666)
let row = UIStackView(arrangedSubviews: [prefix, userAgreementButton, separator, privacyPolicyButton])
row.axis = .horizontal
row.spacing = 0
row.alignment = .center
privacyLabel.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
/// ViewModel
private func bindViewModel() {
viewModel.onChange = { [weak self] in
self?.renderViewModel()
}
renderViewModel()
}
/// render
private func renderViewModel() {
if usernameField.textField.text != viewModel.username {
usernameField.textField.text = viewModel.username
}
if passwordField.textField.text != viewModel.password {
passwordField.textField.text = viewModel.password
}
passwordField.setSecureEntry(!viewModel.showsPassword)
let checkboxImage = viewModel.privacyChecked ? "LoginCheckboxChecked" : "LoginCheckboxUnchecked"
privacyButton.setImage(UIImage(named: checkboxImage), for: .normal)
loginButton.isEnabled = viewModel.canSubmit && !viewModel.isLoading
loginButton.backgroundColor = viewModel.canSubmit ? AppDesign.primary : .gray
loginButton.setTitleColor(.white, for: .normal)
loginButton.alpha = viewModel.isLoading ? 0.7 : 1
if viewModel.showsAgreementSheet {
viewModel.showsAgreementSheet = false
presentAgreementSheet()
}
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 weak var accountSelectionController: AccountSelectionViewController?
/// username
@objc private func usernameChanged() {
viewModel.username = usernameField.textField.text ?? ""
viewModel.normalizeUsernameCountryCodeIfNeeded()
services.toastCenter.dismiss()
}
/// password
@objc private func passwordChanged() {
viewModel.password = passwordField.textField.text ?? ""
services.toastCenter.dismiss()
}
/// togglePrivacy
@objc private func togglePrivacy() {
viewModel.privacyChecked.toggle()
}
/// openUserAgreement
@objc private func openUserAgreement() {
showToast("用户协议页面待接入")
}
/// openPrivacyPolicy
@objc private func openPrivacyPolicy() {
showToast("隐私政策页面待接入")
}
/// login
@objc private func loginTapped() {
view.endEditing(true)
performLogin()
}
/// dismiss
@objc private func dismissKeyboard() {
view.endEditing(true)
}
/// perform
private func performLogin() {
if let validationError = viewModel.validateForLogin() {
if validationError == .privacyUnchecked {
presentAgreementSheet()
} else {
showToast(validationError.message)
if validationError.focusField == .username {
usernameField.textField.becomeFirstResponder()
} else if validationError.focusField == .password {
passwordField.textField.becomeFirstResponder()
}
}
return
}
Task {
do {
try await services.globalLoading.withLoading(message: "登录中...") {
let resolution = try await viewModel.login(authAPI: services.authAPI)
switch resolution {
case let .completed(response):
await completeLogin(with: response)
case .needsAccountSelection:
break
}
}
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
/// AgreementSheet
private func presentAgreementSheet() {
let controller = LoginAgreementConsentViewController(
onOpenAgreement: { [weak self] title in
self?.showToast("\(title)页面待接入")
},
onAgreeAndContinue: { [weak self] in
self?.viewModel.acceptAgreement()
self?.performLogin()
}
)
if let sheet = controller.sheetPresentationController {
sheet.detents = [.custom(resolver: { _ in 240 })]
sheet.prefersGrabberVisible = true
}
present(controller, animated: true)
}
/// AccountSelection
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)
}
/// select
private func selectAccount(_ account: AccountSwitchAccount) {
Task {
do {
try await services.globalLoading.withLoading(message: "账号切换中...") {
let response = try await viewModel.selectAccount(account, authAPI: services.authAPI)
await completeLogin(with: response)
accountSelectionController = nil
dismiss(animated: true)
}
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
/// complete
private func completeLogin(with response: V9AuthResponse) async {
do {
try await services.authSessionCoordinator.completeLogin(
with: response,
username: viewModel.normalizedUsername,
privacyAgreementAccepted: viewModel.privacyChecked,
appSession: services.appSession,
accountContext: services.accountContext,
permissionContext: services.permissionContext,
profileAPI: services.profileAPI,
accountContextAPI: services.accountContextAPI
)
} catch {
showToast(error.localizedDescription)
}
}
}
extension LoginViewController: UITextFieldDelegate {
/// textShouldReturn
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if textField === usernameField.textField {
passwordField.textField.becomeFirstResponder()
} else if viewModel.canSubmit, !viewModel.isLoading {
performLogin()
}
return true
}
}
///
private final class LoginInputField: UIView {
let textField = UITextField()
var onToggleVisibility: (() -> Void)?
private let toggleButton = UIButton(type: .custom)
private var isSecure = false
///
init(iconName: String, placeholder: String, isSecure: Bool) {
self.isSecure = isSecure
super.init(frame: .zero)
backgroundColor = UIColor(hex: 0xF1F5F9)
layer.cornerRadius = AppMetrics.CornerRadius.input
layer.borderWidth = 1
layer.borderColor = UIColor(hex: 0xDDE3EA).cgColor
let icon = UIImageView(image: UIImage(systemName: iconName))
icon.tintColor = AppDesign.textSecondary
icon.contentMode = .scaleAspectFit
textField.placeholder = placeholder
textField.font = .systemFont(ofSize: AppMetrics.FontSize.body)
textField.textColor = AppDesign.textPrimary
textField.tintColor = AppDesign.primary
textField.borderStyle = .none
textField.isSecureTextEntry = isSecure
addSubview(icon)
addSubview(textField)
icon.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.centerY.equalToSuperview()
make.width.height.equalTo(AppMetrics.ControlSize.smallIcon)
}
if isSecure {
toggleButton.addTarget(self, action: #selector(toggleVisibility), for: .touchUpInside)
addSubview(toggleButton)
toggleButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.small)
make.centerY.equalToSuperview()
make.width.height.equalTo(AppMetrics.ControlSize.iconTapArea)
}
textField.snp.makeConstraints { make in
make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall)
make.trailing.equalTo(toggleButton.snp.leading)
make.centerY.equalToSuperview()
}
} else {
textField.snp.makeConstraints { make in
make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall)
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.centerY.equalToSuperview()
}
}
}
required init?(coder: NSCoder) {
nil
}
/// setSecureEntry
func setSecureEntry(_ secure: Bool) {
textField.isSecureTextEntry = secure
let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible"
toggleButton.setImage(UIImage(named: imageName), for: .normal)
}
/// toggleVisibility
@objc private func toggleVisibility() {
onToggleVisibility?()
}
}
///
private final class LoginAgreementConsentViewController: UIViewController {
private let onOpenAgreement: (String) -> Void
private let onAgreeAndContinue: () -> Void
///
init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) {
self.onOpenAgreement = onOpenAgreement
self.onAgreeAndContinue = onAgreeAndContinue
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
nil
}
/// UI
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let titleLabel = UILabel()
titleLabel.text = "请先阅读并同意相关协议"
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
titleLabel.textColor = AppDesign.textPrimary
titleLabel.textAlignment = .center
let descriptionLabel = UILabel()
descriptionLabel.text = "为了保障你的账号安全和服务体验,请阅读并同意用户协议和隐私政策。"
descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
descriptionLabel.textColor = AppDesign.textSecondary
descriptionLabel.numberOfLines = 0
descriptionLabel.textAlignment = .center
let continueButton = UIButton(type: .system)
continueButton.setTitle("同意并继续", for: .normal)
continueButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
continueButton.backgroundColor = AppDesign.primary
continueButton.setTitleColor(.white, for: .normal)
continueButton.layer.cornerRadius = AppMetrics.CornerRadius.button
continueButton.accessibilityIdentifier = "login.agreement.continue"
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
view.addSubview(titleLabel)
view.addSubview(descriptionLabel)
view.addSubview(continueButton)
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.sheet)
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
}
descriptionLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.small)
make.leading.trailing.equalTo(titleLabel)
}
continueButton.snp.makeConstraints { make in
make.leading.trailing.equalTo(titleLabel)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.mediumLarge)
make.height.equalTo(AppMetrics.ControlSize.sheetButtonHeight)
}
}
/// continue
@objc private func continueTapped() {
dismiss(animated: true) { [onAgreeAndContinue] in
onAgreeAndContinue()
}
}
}