// // 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) } }