452 lines
18 KiB
Swift
452 lines
18 KiB
Swift
import SnapKit
|
||
import UIKit
|
||
|
||
/// 线下收款登记页,按 9.7 视觉稿展示金额、支付方式和登记说明。
|
||
@MainActor
|
||
final class OfflineCollectionRegistrationViewController: BaseViewController {
|
||
private let viewModel: OfflineCollectionRegistrationViewModel
|
||
|
||
private let scrollView = UIScrollView()
|
||
private let contentStack = UIStackView()
|
||
private let amountCard = UIView()
|
||
private let amountField = UITextField()
|
||
private let methodCard = UIView()
|
||
private let methodStack = UIStackView()
|
||
private var methodButtons: [OfflineCollectionPaymentMethod: OfflinePaymentMethodButton] = [:]
|
||
private let explanationCard = UIView()
|
||
private let contextLabel = UILabel()
|
||
private let bottomContainer = UIView()
|
||
private let submitButton = OfflineCollectionGradientButton(
|
||
startColor: UIColor(hex: 0x087BFF),
|
||
endColor: UIColor(hex: 0x0067F4)
|
||
)
|
||
private var hasFocusedAmountField = false
|
||
private var isShowingGlobalLoading = false
|
||
|
||
init(
|
||
context: OfflineCollectionContext = .current(),
|
||
api: any OfflineCollectionServing = NetworkServices.shared.offlineCollectionAPI
|
||
) {
|
||
viewModel = OfflineCollectionRegistrationViewModel(context: context, api: api)
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
override func setupNavigationBar() {
|
||
title = "线下收款登记"
|
||
}
|
||
|
||
override func setupUI() {
|
||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||
configureScrollContent()
|
||
configureAmountCard()
|
||
configureMethodCard()
|
||
configureExplanationCard()
|
||
configureContextLabel()
|
||
configureBottomBar()
|
||
|
||
view.addSubview(scrollView)
|
||
scrollView.addSubview(contentStack)
|
||
[amountCard, methodCard, explanationCard, contextLabel].forEach(contentStack.addArrangedSubview)
|
||
view.addSubview(bottomContainer)
|
||
bottomContainer.addSubview(submitButton)
|
||
}
|
||
|
||
override func setupConstraints() {
|
||
bottomContainer.snp.makeConstraints { make in
|
||
make.leading.trailing.equalToSuperview()
|
||
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||
}
|
||
submitButton.snp.makeConstraints { make in
|
||
make.top.equalToSuperview().offset(16)
|
||
make.leading.trailing.equalToSuperview().inset(18)
|
||
make.height.equalTo(56)
|
||
make.bottom.equalToSuperview().inset(16)
|
||
}
|
||
scrollView.snp.makeConstraints { make in
|
||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||
make.bottom.equalTo(bottomContainer.snp.top)
|
||
}
|
||
contentStack.snp.makeConstraints { make in
|
||
make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 16, bottom: 24, right: 16))
|
||
make.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
|
||
}
|
||
}
|
||
|
||
override func bindActions() {
|
||
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
|
||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
||
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
|
||
viewModel.onRegistrationSuccess = { [weak self] receipt in Task { @MainActor in self?.showSuccess(receipt) } }
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
applyState()
|
||
}
|
||
|
||
override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
guard !hasFocusedAmountField else { return }
|
||
hasFocusedAmountField = true
|
||
amountField.becomeFirstResponder()
|
||
moveAmountCursorToEnd()
|
||
}
|
||
|
||
override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
setGlobalLoadingVisible(false)
|
||
}
|
||
|
||
private func configureScrollContent() {
|
||
scrollView.keyboardDismissMode = .interactive
|
||
scrollView.alwaysBounceVertical = true
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
contentStack.axis = .vertical
|
||
contentStack.spacing = 16
|
||
}
|
||
|
||
private func configureAmountCard() {
|
||
configureCard(amountCard)
|
||
|
||
let titleLabel = makeTitleLabel("收款金额")
|
||
let helperLabel = UILabel()
|
||
helperLabel.text = "单笔金额 0.01~99,999.99 元"
|
||
helperLabel.font = .systemFont(ofSize: 13)
|
||
helperLabel.textColor = UIColor(hex: 0x7B8494)
|
||
helperLabel.adjustsFontSizeToFitWidth = true
|
||
helperLabel.minimumScaleFactor = 0.82
|
||
|
||
let currencyLabel = UILabel()
|
||
currencyLabel.text = "¥"
|
||
currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold)
|
||
currencyLabel.textColor = UIColor(hex: 0x081739)
|
||
|
||
amountField.placeholder = "0.00"
|
||
amountField.font = .systemFont(ofSize: 42, weight: .bold)
|
||
amountField.textColor = UIColor(hex: 0x081739)
|
||
amountField.tintColor = AppColor.primary
|
||
amountField.textAlignment = .right
|
||
amountField.keyboardType = .decimalPad
|
||
amountField.adjustsFontSizeToFitWidth = true
|
||
amountField.minimumFontSize = 30
|
||
amountField.delegate = self
|
||
amountField.accessibilityLabel = "收款金额"
|
||
amountField.accessibilityIdentifier = "offlineCollection.amount"
|
||
|
||
let amountInputStack = UIStackView(arrangedSubviews: [currencyLabel, amountField])
|
||
amountInputStack.axis = .horizontal
|
||
amountInputStack.alignment = .center
|
||
amountInputStack.spacing = 6
|
||
|
||
let amountRow = UIView()
|
||
amountRow.addSubview(helperLabel)
|
||
amountRow.addSubview(amountInputStack)
|
||
helperLabel.snp.makeConstraints { make in
|
||
make.leading.centerY.equalToSuperview()
|
||
make.trailing.lessThanOrEqualTo(amountInputStack.snp.leading).offset(-8)
|
||
}
|
||
amountInputStack.snp.makeConstraints { make in
|
||
make.trailing.centerY.equalToSuperview()
|
||
make.width.lessThanOrEqualTo(205)
|
||
}
|
||
amountField.snp.makeConstraints { make in
|
||
make.height.equalTo(56)
|
||
make.width.greaterThanOrEqualTo(98)
|
||
}
|
||
|
||
let underline = UIView()
|
||
underline.backgroundColor = UIColor(hex: 0x1684FC)
|
||
underline.snp.makeConstraints { make in make.height.equalTo(1) }
|
||
|
||
let quickAmounts: [(String, Int)] = [("¥50", 5_000), ("¥100", 10_000), ("¥200", 20_000), ("¥500", 50_000)]
|
||
let quickStack = UIStackView()
|
||
quickStack.axis = .horizontal
|
||
quickStack.distribution = .fillEqually
|
||
quickStack.spacing = 12
|
||
quickAmounts.forEach { title, fen in
|
||
let button = UIButton(type: .system)
|
||
button.tag = fen
|
||
button.setTitle(title, for: .normal)
|
||
button.setTitleColor(UIColor(hex: 0x1677FF), for: .normal)
|
||
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
|
||
button.backgroundColor = UIColor(hex: 0xF5F8FD)
|
||
button.layer.cornerRadius = 8
|
||
button.layer.borderWidth = 1
|
||
button.layer.borderColor = UIColor(hex: 0xE3E8F0).cgColor
|
||
button.addTarget(self, action: #selector(quickAmountTapped(_:)), for: .touchUpInside)
|
||
button.snp.makeConstraints { make in make.height.equalTo(36) }
|
||
quickStack.addArrangedSubview(button)
|
||
}
|
||
|
||
let stack = UIStackView(arrangedSubviews: [titleLabel, amountRow, underline, quickStack])
|
||
stack.axis = .vertical
|
||
stack.setCustomSpacing(8, after: titleLabel)
|
||
stack.setCustomSpacing(2, after: amountRow)
|
||
stack.setCustomSpacing(22, after: underline)
|
||
amountCard.addSubview(stack)
|
||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) }
|
||
amountRow.snp.makeConstraints { make in make.height.equalTo(56) }
|
||
}
|
||
|
||
private func configureMethodCard() {
|
||
configureCard(methodCard)
|
||
let titleLabel = makeTitleLabel("收款方式")
|
||
methodStack.axis = .horizontal
|
||
methodStack.distribution = .fillEqually
|
||
methodStack.spacing = 12
|
||
for method in OfflineCollectionPaymentMethod.allCases {
|
||
let button = OfflinePaymentMethodButton(method: method)
|
||
button.addTarget(self, action: #selector(methodTapped(_:)), for: .touchUpInside)
|
||
methodButtons[method] = button
|
||
methodStack.addArrangedSubview(button)
|
||
}
|
||
let stack = UIStackView(arrangedSubviews: [titleLabel, methodStack])
|
||
stack.axis = .vertical
|
||
stack.spacing = 20
|
||
methodCard.addSubview(stack)
|
||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) }
|
||
methodStack.snp.makeConstraints { make in make.height.equalTo(120) }
|
||
}
|
||
|
||
private func configureExplanationCard() {
|
||
configureCard(explanationCard)
|
||
let iconView = UIImageView(image: UIImage(named: "offline_security_shield"))
|
||
iconView.contentMode = .scaleAspectFit
|
||
iconView.accessibilityLabel = "安全说明"
|
||
|
||
let label = UILabel()
|
||
label.text = "登记后计入今日待补缴,不生成订单"
|
||
label.font = .systemFont(ofSize: 15)
|
||
label.textColor = UIColor(hex: 0x22304D)
|
||
label.numberOfLines = 0
|
||
|
||
explanationCard.addSubview(iconView)
|
||
explanationCard.addSubview(label)
|
||
iconView.snp.makeConstraints { make in
|
||
make.leading.equalToSuperview().offset(20)
|
||
make.centerY.equalToSuperview()
|
||
make.width.height.equalTo(44)
|
||
}
|
||
label.snp.makeConstraints { make in
|
||
make.leading.equalTo(iconView.snp.trailing).offset(14)
|
||
make.trailing.equalToSuperview().inset(18)
|
||
make.top.bottom.equalToSuperview().inset(20)
|
||
}
|
||
explanationCard.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(72) }
|
||
}
|
||
|
||
private func configureContextLabel() {
|
||
contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)"
|
||
contextLabel.font = .systemFont(ofSize: 13)
|
||
contextLabel.textColor = UIColor(hex: 0x7B8494)
|
||
contextLabel.numberOfLines = 0
|
||
contextLabel.textAlignment = .center
|
||
contextLabel.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) }
|
||
}
|
||
|
||
private func configureBottomBar() {
|
||
bottomContainer.backgroundColor = .white
|
||
bottomContainer.layer.shadowColor = UIColor(hex: 0x8090A8).cgColor
|
||
bottomContainer.layer.shadowOpacity = 0.12
|
||
bottomContainer.layer.shadowRadius = 12
|
||
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -3)
|
||
submitButton.setTitle("确认登记", for: .normal)
|
||
submitButton.setTitleColor(.white, for: .normal)
|
||
submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold)
|
||
submitButton.layer.cornerRadius = 10
|
||
submitButton.clipsToBounds = true
|
||
submitButton.accessibilityIdentifier = "offlineCollection.submit"
|
||
}
|
||
|
||
@MainActor private func applyState() {
|
||
setGlobalLoadingVisible(viewModel.isSubmitting)
|
||
if amountField.text != viewModel.amountText {
|
||
amountField.text = viewModel.amountText
|
||
moveAmountCursorToEnd()
|
||
}
|
||
methodButtons.forEach { method, button in button.setSelected(method == viewModel.paymentMethod) }
|
||
submitButton.isEnabled = viewModel.canSubmit && !viewModel.isSubmitting
|
||
submitButton.alpha = viewModel.canSubmit || viewModel.isSubmitting ? 1 : 0.45
|
||
}
|
||
|
||
@MainActor private func showSuccess(_ receipt: OfflineCollectionRegistrationReceipt) {
|
||
setGlobalLoadingVisible(false)
|
||
amountField.resignFirstResponder()
|
||
let totalText = receipt.summary.map { OfflineCollectionMoney.display($0.totalAmountFen) } ?? "—"
|
||
let pendingText = receipt.summary.map { OfflineCollectionMoney.display($0.pendingAmountFen) } ?? "—"
|
||
let message = """
|
||
本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen))
|
||
今日累计线下收款 \(totalText)
|
||
今日待补缴 \(pendingText)
|
||
"""
|
||
let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert)
|
||
alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in
|
||
self?.viewModel.startAnotherRegistration()
|
||
self?.amountField.becomeFirstResponder()
|
||
self?.moveAmountCursorToEnd()
|
||
})
|
||
alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in
|
||
guard let self else { return }
|
||
self.navigationController?.pushViewController(
|
||
OfflineCollectionDailyViewController(
|
||
businessDate: receipt.businessDate,
|
||
context: self.viewModel.context,
|
||
api: self.viewModel.api
|
||
),
|
||
animated: true
|
||
)
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private func setGlobalLoadingVisible(_ visible: Bool) {
|
||
guard visible != isShowingGlobalLoading else { return }
|
||
isShowingGlobalLoading = visible
|
||
visible ? showLoading() : hideLoading()
|
||
}
|
||
|
||
private func makeTitleLabel(_ text: String) -> UILabel {
|
||
let label = UILabel()
|
||
label.text = text
|
||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||
label.textColor = UIColor(hex: 0x081739)
|
||
return label
|
||
}
|
||
|
||
private func configureCard(_ card: UIView) {
|
||
card.backgroundColor = .white
|
||
card.layer.cornerRadius = 12
|
||
card.layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor
|
||
card.layer.shadowOpacity = 0.12
|
||
card.layer.shadowRadius = 10
|
||
card.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||
}
|
||
|
||
private func moveAmountCursorToEnd() {
|
||
guard amountField.isFirstResponder else { return }
|
||
let end = amountField.endOfDocument
|
||
amountField.selectedTextRange = amountField.textRange(from: end, to: end)
|
||
}
|
||
|
||
@objc private func amountChanged() {
|
||
viewModel.updateAmount(amountField.text ?? "")
|
||
moveAmountCursorToEnd()
|
||
}
|
||
|
||
@objc private func quickAmountTapped(_ sender: UIButton) {
|
||
viewModel.updateAmount(OfflineCollectionMoney.apiAmount(sender.tag))
|
||
amountField.becomeFirstResponder()
|
||
moveAmountCursorToEnd()
|
||
}
|
||
|
||
@objc private func methodTapped(_ sender: OfflinePaymentMethodButton) {
|
||
viewModel.selectPaymentMethod(sender.method)
|
||
}
|
||
|
||
@objc private func submitTapped() {
|
||
Task { await viewModel.submit() }
|
||
}
|
||
}
|
||
|
||
extension OfflineCollectionRegistrationViewController: UITextFieldDelegate {
|
||
func textField(
|
||
_ textField: UITextField,
|
||
shouldChangeCharactersIn range: NSRange,
|
||
replacementString string: String
|
||
) -> Bool {
|
||
guard let current = textField.text, let swiftRange = Range(range, in: current) else { return false }
|
||
return OfflineCollectionMoney.acceptsEditingText(current.replacingCharacters(in: swiftRange, with: string))
|
||
}
|
||
}
|
||
|
||
/// 登记页的收款方式单选卡,展示官方品牌图标和右上角选中标记。
|
||
@MainActor
|
||
final class OfflinePaymentMethodButton: UIControl {
|
||
let method: OfflineCollectionPaymentMethod
|
||
|
||
private let iconView = UIImageView()
|
||
private let titleLabel = UILabel()
|
||
private let checkmarkView = UIImageView(image: UIImage(systemName: "checkmark"))
|
||
|
||
init(method: OfflineCollectionPaymentMethod) {
|
||
self.method = method
|
||
super.init(frame: .zero)
|
||
setupUI()
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
func setSelected(_ selected: Bool) {
|
||
isSelected = selected
|
||
backgroundColor = selected ? UIColor(hex: 0xF1F7FF) : .white
|
||
layer.borderColor = (selected ? UIColor(hex: 0x1684FC) : UIColor(hex: 0xE2E7EF)).cgColor
|
||
layer.borderWidth = selected ? 1.5 : 1
|
||
checkmarkView.isHidden = !selected
|
||
titleLabel.textColor = UIColor(hex: 0x081739)
|
||
accessibilityTraits = selected ? [.button, .selected] : .button
|
||
}
|
||
|
||
private func setupUI() {
|
||
layer.cornerRadius = 10
|
||
clipsToBounds = false
|
||
accessibilityLabel = method.displayName
|
||
iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal)
|
||
iconView.contentMode = .scaleAspectFit
|
||
titleLabel.text = method.displayName
|
||
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||
titleLabel.textAlignment = .center
|
||
checkmarkView.tintColor = .white
|
||
checkmarkView.backgroundColor = UIColor(hex: 0x1684FC)
|
||
checkmarkView.layer.cornerRadius = 11
|
||
checkmarkView.contentMode = .center
|
||
checkmarkView.isHidden = true
|
||
|
||
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
||
stack.axis = .vertical
|
||
stack.alignment = .center
|
||
stack.spacing = 12
|
||
stack.isUserInteractionEnabled = false
|
||
addSubview(stack)
|
||
addSubview(checkmarkView)
|
||
stack.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||
iconView.snp.makeConstraints { make in make.width.height.equalTo(46) }
|
||
checkmarkView.snp.makeConstraints { make in
|
||
make.width.height.equalTo(22)
|
||
make.top.trailing.equalToSuperview().inset(-2)
|
||
}
|
||
setSelected(false)
|
||
}
|
||
}
|
||
|
||
/// 线下收款页面共用的双端色渐变按钮。
|
||
@MainActor
|
||
final class OfflineCollectionGradientButton: UIButton {
|
||
private let gradientLayer = CAGradientLayer()
|
||
|
||
init(startColor: UIColor, endColor: UIColor) {
|
||
super.init(frame: .zero)
|
||
gradientLayer.colors = [startColor.cgColor, endColor.cgColor]
|
||
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||
layer.insertSublayer(gradientLayer, at: 0)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
gradientLayer.frame = bounds
|
||
gradientLayer.cornerRadius = layer.cornerRadius
|
||
}
|
||
|
||
override var isEnabled: Bool {
|
||
didSet { gradientLayer.opacity = isEnabled ? 1 : 0.45 }
|
||
}
|
||
}
|