Files
suixinkan_uikit/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift
T

341 lines
14 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.
//
// OfflineCollectionRegistrationViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 线下收款登记页,仅收集金额和线下收款方式。
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 = UIButton(type: .system)
private var hasFocusedAmountField = false
/// 使用指定的收款上下文与 Mock 服务创建登记页。
init(
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service)
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 = AppColor.pageBackground
scrollView.keyboardDismissMode = .interactive
scrollView.alwaysBounceVertical = true
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md
configureCard(amountCard)
let amountTitleLabel = makeTitleLabel("收款金额")
let currencyLabel = UILabel()
currencyLabel.text = "¥"
currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold)
currencyLabel.textColor = AppColor.textPrimary
amountField.placeholder = "0.00"
amountField.font = .systemFont(ofSize: 38, weight: .bold)
amountField.textColor = AppColor.textPrimary
amountField.keyboardType = .decimalPad
amountField.clearButtonMode = .whileEditing
amountField.delegate = self
amountField.accessibilityLabel = "收款金额"
let amountHelperLabel = UILabel()
amountHelperLabel.text = "单笔金额 0.01~99,999.99 元,最多两位小数"
amountHelperLabel.font = .systemFont(ofSize: 12)
amountHelperLabel.textColor = AppColor.textTertiary
let amountRow = UIStackView(arrangedSubviews: [currencyLabel, amountField])
amountRow.axis = .horizontal
amountRow.alignment = .center
amountRow.spacing = AppSpacing.sm
let amountStack = UIStackView(arrangedSubviews: [amountTitleLabel, amountRow, amountHelperLabel])
amountStack.axis = .vertical
amountStack.spacing = AppSpacing.sm
amountCard.addSubview(amountStack)
amountStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.lg)
}
amountField.snp.makeConstraints { make in
make.height.equalTo(58)
}
configureCard(methodCard)
let methodTitleLabel = makeTitleLabel("收款方式")
methodStack.axis = .horizontal
methodStack.distribution = .fillEqually
methodStack.spacing = AppSpacing.sm
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 methodContentStack = UIStackView(arrangedSubviews: [methodTitleLabel, methodStack])
methodContentStack.axis = .vertical
methodContentStack.spacing = AppSpacing.md
methodCard.addSubview(methodContentStack)
methodContentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.lg)
}
methodStack.snp.makeConstraints { make in
make.height.equalTo(64)
}
configureCard(explanationCard)
let explanationIcon = UIImageView(image: UIImage(systemName: "info.circle.fill"))
explanationIcon.tintColor = AppColor.primary
explanationIcon.contentMode = .scaleAspectFit
let explanationLabel = UILabel()
explanationLabel.text = "登记后将计入今日待补缴,不生成订单。"
explanationLabel.font = .systemFont(ofSize: 14)
explanationLabel.textColor = AppColor.textSecondary
explanationLabel.numberOfLines = 0
explanationCard.addSubview(explanationIcon)
explanationCard.addSubview(explanationLabel)
explanationIcon.snp.makeConstraints { make in
make.leading.top.equalToSuperview().inset(AppSpacing.md)
make.width.height.equalTo(20)
}
explanationLabel.snp.makeConstraints { make in
make.leading.equalTo(explanationIcon.snp.trailing).offset(AppSpacing.sm)
make.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
make.top.equalToSuperview().inset(AppSpacing.md)
}
contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)"
contextLabel.font = .systemFont(ofSize: 12)
contextLabel.textColor = AppColor.textTertiary
contextLabel.numberOfLines = 0
contextLabel.textAlignment = .center
bottomContainer.backgroundColor = .white
bottomContainer.layer.shadowColor = UIColor.black.cgColor
bottomContainer.layer.shadowOpacity = 0.06
bottomContainer.layer.shadowRadius = 8
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2)
var submitConfiguration = UIButton.Configuration.filled()
submitConfiguration.title = "确认登记"
submitConfiguration.baseBackgroundColor = AppColor.primary
submitConfiguration.baseForegroundColor = .white
submitConfiguration.cornerStyle = .medium
submitConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
return outgoing
}
submitButton.configuration = submitConfiguration
submitButton.accessibilityIdentifier = "offlineCollection.submit"
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().inset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.height.equalTo(50)
make.bottom.equalToSuperview().inset(AppSpacing.md)
}
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.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
}
}
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?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onRegistrationSuccess = { [weak self] receipt in
Task { @MainActor in self?.showRegistrationSuccess(receipt) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !hasFocusedAmountField else { return }
hasFocusedAmountField = true
amountField.becomeFirstResponder()
}
@MainActor
private func applyViewModel() {
if amountField.text != viewModel.amountText {
amountField.text = viewModel.amountText
}
methodButtons.forEach { method, button in
button.setSelected(method == viewModel.paymentMethod)
}
submitButton.isEnabled = viewModel.canSubmit
submitButton.configuration?.showsActivityIndicator = viewModel.isSubmitting
submitButton.configuration?.title = viewModel.isSubmitting ? "登记中" : "确认登记"
submitButton.alpha = submitButton.isEnabled || viewModel.isSubmitting ? 1 : 0.45
}
@MainActor
private func showRegistrationSuccess(_ receipt: OfflineCollectionRegistrationReceipt) {
amountField.resignFirstResponder()
let message = """
本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen))
今日累计线下收款 \(OfflineCollectionMoney.display(receipt.summary.totalAmountFen))
今日待补缴 \(OfflineCollectionMoney.display(receipt.summary.pendingAmountFen))
"""
let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in
self?.viewModel.startAnotherRegistration()
self?.amountField.becomeFirstResponder()
})
alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in
guard let self else { return }
let controller = OfflineCollectionDailyViewController(
businessDate: receipt.summary.businessDate,
context: self.viewModel.context,
service: self.viewModel.service
)
self.navigationController?.pushViewController(controller, animated: true)
})
present(alert, animated: true)
}
private func makeTitleLabel(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 16, weight: .semibold)
label.textColor = AppColor.textPrimary
return label
}
private func configureCard(_ card: UIView) {
card.backgroundColor = .white
card.layer.cornerRadius = AppRadius.lg
}
@objc private func amountChanged() {
viewModel.updateAmount(amountField.text ?? "")
}
@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 textRange = Range(range, in: current) else { return false }
let next = current.replacingCharacters(in: textRange, with: string)
return OfflineCollectionMoney.acceptsEditingText(next)
}
}
/// 登记页的收款方式单选按钮。
final class OfflinePaymentMethodButton: UIControl {
let method: OfflineCollectionPaymentMethod
private let iconView = UIImageView()
private let titleLabel = UILabel()
/// 使用指定收款方式创建单选按钮。
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: 0xEEF5FF) : AppColor.inputBackground
layer.borderColor = (selected ? AppColor.primary : UIColor.clear).cgColor
layer.borderWidth = selected ? 1.5 : 0
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
}
private func setupUI() {
layer.cornerRadius = AppRadius.md
clipsToBounds = true
accessibilityLabel = method.displayName
iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal)
iconView.contentMode = .scaleAspectFit
titleLabel.text = method.displayName
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 5
stack.isUserInteractionEnabled = false
addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(22)
}
setSelected(false)
}
}