Add login flow, session routing, and MainTab shell aligned with reference UI.
Implement token-based root routing, Android-matched login screen, five-slot MainTabBar with scan action, and placeholder tab pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
114
suixinkan/UI/Common/AgreementRowView.swift
Normal file
114
suixinkan/UI/Common/AgreementRowView.swift
Normal file
@ -0,0 +1,114 @@
|
||||
//
|
||||
// AgreementRowView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 协议勾选行,对应 Android `AgreementRow`。
|
||||
final class AgreementRowView: UIView, UITextViewDelegate {
|
||||
|
||||
private let checkButton = UIButton(type: .custom)
|
||||
private let textView = UITextView()
|
||||
|
||||
var isChecked = false {
|
||||
didSet { updateCheckboxImage() }
|
||||
}
|
||||
|
||||
var onCheckedChange: (() -> Void)?
|
||||
var onUserAgreementTap: (() -> Void)?
|
||||
var onPrivacyPolicyTap: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
checkButton.addTarget(self, action: #selector(checkButtonTapped), for: .touchUpInside)
|
||||
updateCheckboxImage()
|
||||
|
||||
textView.backgroundColor = .clear
|
||||
textView.isEditable = false
|
||||
textView.isScrollEnabled = false
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
textView.delegate = self
|
||||
textView.linkTextAttributes = [
|
||||
.foregroundColor: AppColor.agreementLink,
|
||||
.font: UIFont.systemFont(ofSize: 12, weight: .semibold),
|
||||
]
|
||||
textView.attributedText = makeAgreementText()
|
||||
|
||||
addSubview(checkButton)
|
||||
addSubview(textView)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
checkButton.snp.makeConstraints { make in
|
||||
make.leading.top.equalToSuperview()
|
||||
make.size.equalTo(18)
|
||||
}
|
||||
|
||||
textView.snp.makeConstraints { make in
|
||||
make.leading.equalTo(checkButton.snp.trailing).offset(8)
|
||||
make.trailing.top.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCheckboxImage() {
|
||||
let imageName = isChecked ? "icon_cb_privacy_selected" : "icon_cb_privacy_unselect"
|
||||
checkButton.setImage(UIImage(named: imageName), for: .normal)
|
||||
}
|
||||
|
||||
private func makeAgreementText() -> NSAttributedString {
|
||||
let result = NSMutableAttributedString()
|
||||
let normalAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.systemFont(ofSize: 12),
|
||||
.foregroundColor: AppColor.text666,
|
||||
]
|
||||
let linkFont = UIFont.systemFont(ofSize: 12, weight: .semibold)
|
||||
|
||||
result.append(NSAttributedString(string: "已阅读并同意", attributes: normalAttributes))
|
||||
result.append(NSAttributedString(string: "《用户协议》", attributes: [
|
||||
.font: linkFont,
|
||||
.foregroundColor: AppColor.agreementLink,
|
||||
.link: "tos",
|
||||
]))
|
||||
result.append(NSAttributedString(string: "与", attributes: normalAttributes))
|
||||
result.append(NSAttributedString(string: "《隐私政策》", attributes: [
|
||||
.font: linkFont,
|
||||
.foregroundColor: AppColor.agreementLink,
|
||||
.link: "privacy",
|
||||
]))
|
||||
return result
|
||||
}
|
||||
|
||||
@objc private func checkButtonTapped() {
|
||||
onCheckedChange?()
|
||||
}
|
||||
|
||||
func textView(
|
||||
_ textView: UITextView,
|
||||
shouldInteractWith URL: URL,
|
||||
in characterRange: NSRange,
|
||||
interaction: UITextItemInteraction
|
||||
) -> Bool {
|
||||
switch URL.absoluteString {
|
||||
case "tos":
|
||||
onUserAgreementTap?()
|
||||
case "privacy":
|
||||
onPrivacyPolicyTap?()
|
||||
default:
|
||||
break
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
59
suixinkan/UI/Common/AppButton.swift
Normal file
59
suixinkan/UI/Common/AppButton.swift
Normal file
@ -0,0 +1,59 @@
|
||||
//
|
||||
// AppButton.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 主按钮,对应 Android `AppButton`。
|
||||
final class AppButton: UIButton {
|
||||
|
||||
enum Style {
|
||||
case primary
|
||||
case secondary
|
||||
}
|
||||
|
||||
private let style: Style
|
||||
|
||||
init(title: String, style: Style = .primary) {
|
||||
self.style = style
|
||||
super.init(frame: .zero)
|
||||
setupUI(title: title)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var isEnabled: Bool {
|
||||
didSet { updateAppearance() }
|
||||
}
|
||||
|
||||
private func setupUI(title: String) {
|
||||
titleLabel?.font = .systemFont(ofSize: 14, weight: .regular)
|
||||
layer.cornerRadius = 10
|
||||
clipsToBounds = true
|
||||
setTitle(title, for: .normal)
|
||||
updateAppearance()
|
||||
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(46)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAppearance() {
|
||||
switch style {
|
||||
case .primary:
|
||||
backgroundColor = isEnabled ? AppColor.primary : AppColor.buttonDisabled
|
||||
setTitleColor(.white, for: .normal)
|
||||
setTitleColor(.white, for: .disabled)
|
||||
case .secondary:
|
||||
backgroundColor = AppColor.secondaryButtonBackground
|
||||
setTitleColor(AppColor.primary, for: .normal)
|
||||
setTitleColor(AppColor.primary, for: .disabled)
|
||||
alpha = isEnabled ? 1 : 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
83
suixinkan/UI/Common/LoginTextField.swift
Normal file
83
suixinkan/UI/Common/LoginTextField.swift
Normal file
@ -0,0 +1,83 @@
|
||||
//
|
||||
// LoginTextField.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 登录页账号输入框,对应 Android `PhoneInputField`。
|
||||
final class LoginTextField: UIView {
|
||||
|
||||
private let textField = UITextField()
|
||||
|
||||
var text: String {
|
||||
get { textField.text ?? "" }
|
||||
set { textField.text = newValue }
|
||||
}
|
||||
|
||||
var onTextChange: ((String) -> Void)?
|
||||
var onReturnKey: (() -> Void)?
|
||||
|
||||
init(placeholder: String) {
|
||||
super.init(frame: .zero)
|
||||
setupUI(placeholder: placeholder)
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
textField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
private func setupUI(placeholder: String) {
|
||||
backgroundColor = AppColor.inputBackground
|
||||
layer.cornerRadius = 12
|
||||
clipsToBounds = true
|
||||
|
||||
textField.font = .systemFont(ofSize: 14)
|
||||
textField.textColor = AppColor.text333
|
||||
textField.autocapitalizationType = .none
|
||||
textField.autocorrectionType = .no
|
||||
textField.returnKeyType = .next
|
||||
textField.clearButtonMode = .whileEditing
|
||||
textField.delegate = self
|
||||
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.foregroundColor: AppColor.placeholder,
|
||||
.font: UIFont.systemFont(ofSize: 14),
|
||||
]
|
||||
textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes)
|
||||
|
||||
textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
|
||||
addSubview(textField)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(46)
|
||||
}
|
||||
|
||||
textField.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func textDidChange() {
|
||||
onTextChange?(text)
|
||||
}
|
||||
}
|
||||
|
||||
extension LoginTextField: UITextFieldDelegate {
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
onReturnKey?()
|
||||
return true
|
||||
}
|
||||
}
|
||||
104
suixinkan/UI/Common/PasswordInputField.swift
Normal file
104
suixinkan/UI/Common/PasswordInputField.swift
Normal file
@ -0,0 +1,104 @@
|
||||
//
|
||||
// PasswordInputField.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 登录页密码输入框,对应 Android `PasswordInputField`。
|
||||
final class PasswordInputField: UIView {
|
||||
|
||||
private let textField = UITextField()
|
||||
private let toggleButton = UIButton(type: .custom)
|
||||
|
||||
private var isPasswordVisible = false
|
||||
|
||||
var text: String {
|
||||
get { textField.text ?? "" }
|
||||
set { textField.text = newValue }
|
||||
}
|
||||
|
||||
var onTextChange: ((String) -> Void)?
|
||||
var onReturnKey: (() -> Void)?
|
||||
|
||||
init(placeholder: String = "请输入密码") {
|
||||
super.init(frame: .zero)
|
||||
setupUI(placeholder: placeholder)
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
textField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
private func setupUI(placeholder: String) {
|
||||
backgroundColor = AppColor.inputBackground
|
||||
layer.cornerRadius = 12
|
||||
clipsToBounds = true
|
||||
|
||||
textField.font = .systemFont(ofSize: 14)
|
||||
textField.textColor = AppColor.text333
|
||||
textField.isSecureTextEntry = true
|
||||
textField.autocapitalizationType = .none
|
||||
textField.autocorrectionType = .no
|
||||
textField.returnKeyType = .done
|
||||
textField.delegate = self
|
||||
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.foregroundColor: AppColor.placeholder,
|
||||
.font: UIFont.systemFont(ofSize: 14),
|
||||
]
|
||||
textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes)
|
||||
textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged)
|
||||
|
||||
toggleButton.setImage(UIImage(named: "icon_pwd_invisible"), for: .normal)
|
||||
toggleButton.addTarget(self, action: #selector(togglePasswordVisibility), for: .touchUpInside)
|
||||
|
||||
addSubview(textField)
|
||||
addSubview(toggleButton)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(46)
|
||||
}
|
||||
|
||||
toggleButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(8)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(30)
|
||||
}
|
||||
|
||||
textField.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().inset(16)
|
||||
make.trailing.equalTo(toggleButton.snp.leading).offset(-4)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func textDidChange() {
|
||||
onTextChange?(text)
|
||||
}
|
||||
|
||||
@objc private func togglePasswordVisibility() {
|
||||
isPasswordVisible.toggle()
|
||||
textField.isSecureTextEntry = !isPasswordVisible
|
||||
let imageName = isPasswordVisible ? "icon_pwd_visible" : "icon_pwd_invisible"
|
||||
toggleButton.setImage(UIImage(named: imageName), for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
extension PasswordInputField: UITextFieldDelegate {
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
onReturnKey?()
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user