Files
suixinkan_uikit/suixinkan/UI/Wallet/PointsRedemptionViewController.swift

469 lines
18 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.

//
// PointsRedemptionViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `PointsRedemptionScreen`
final class PointsRedemptionViewController: BaseViewController, UITableViewDelegate {
private enum Section {
case main
}
private let viewModel: PointsRedemptionViewModel
private let walletAPI: any WalletPageServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let pointsCard = UIView()
private let pointsValueLabel = UILabel()
private let inputCard = UIView()
private let pointsInputField = UITextField()
private let withdrawablePointsLabel = UILabel()
private let recordCard = UIView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let bottomBar = UIView()
private let submitButton = AppButton(title: "立即兑换")
private var dataSource: UITableViewDiffableDataSource<Section, PointWithdrawItem>!
private var lastShownMessage: String?
///
init(
viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
) {
self.viewModel = viewModel
self.walletAPI = walletAPI
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: 0xF5F6F8)
contentStack.axis = .vertical
contentStack.spacing = 16
bottomBar.backgroundColor = .white
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(bottomBar)
bottomBar.addSubview(submitButton)
contentStack.addArrangedSubview(makeSummaryCard())
contentStack.addArrangedSubview(makeInputCard())
contentStack.addArrangedSubview(makeRecordCard())
configureTable()
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
tableView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(260)
make.height.lessThanOrEqualTo(420)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyState() }
}
pointsInputField.addTarget(self, action: #selector(pointsInputChanged), for: .editingChanged)
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
Task { await viewModel.loadInitial(api: walletAPI) }
}
private func configureTable() {
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
tableView.register(PointWithdrawRecordCell.self, forCellReuseIdentifier: PointWithdrawRecordCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Section, PointWithdrawItem>(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: PointWithdrawRecordCell.reuseIdentifier,
for: indexPath
) as? PointWithdrawRecordCell
cell?.apply(item)
return cell ?? UITableViewCell()
}
}
private func applyState() {
pointsValueLabel.text = "\(viewModel.withdrawnPoints)"
withdrawablePointsLabel.text = "\(viewModel.withdrawnPoints)"
pointsInputField.text = viewModel.pointsInput
submitButton.isEnabled = (Int(viewModel.pointsInput) ?? 0) > 0
refreshControl.endRefreshing()
var snapshot = NSDiffableDataSourceSnapshot<Section, PointWithdrawItem>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.withdrawRecords, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState()
showMessageIfNeeded(viewModel.statusMessage)
showMessageIfNeeded(viewModel.errorMessage)
}
private func updateEmptyState() {
guard viewModel.withdrawRecords.isEmpty, !viewModel.withdrawLoading else {
tableView.backgroundView = nil
return
}
let label = UILabel()
label.text = "暂无提现记录"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0xB3B8C2)
label.textAlignment = .center
tableView.backgroundView = label
}
private func makeSummaryCard() -> UIView {
pointsCard.backgroundColor = UIColor(hex: 0x1677FF)
pointsCard.layer.cornerRadius = 16
pointsCard.clipsToBounds = true
let title = UILabel()
title.text = "可提现积分"
title.font = .systemFont(ofSize: 14)
title.textColor = UIColor.white.withAlphaComponent(0.8)
title.textAlignment = .center
pointsValueLabel.font = .systemFont(ofSize: 36, weight: .bold)
pointsValueLabel.textColor = .white
pointsValueLabel.textAlignment = .center
pointsCard.addSubview(title)
pointsCard.addSubview(pointsValueLabel)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(24)
}
pointsValueLabel.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(24)
make.bottom.equalToSuperview().inset(16)
}
return pointsCard
}
private func makeInputCard() -> UIView {
inputCard.backgroundColor = .white
inputCard.layer.cornerRadius = 12
inputCard.clipsToBounds = true
let title = makeTitle("积分提现", size: 18)
let row = UIStackView()
row.axis = .horizontal
row.spacing = 16
let label = makeBody("可提现积分", color: .black, weight: .medium)
withdrawablePointsLabel.font = .systemFont(ofSize: 14, weight: .medium)
withdrawablePointsLabel.textColor = UIColor(hex: 0xFF7B00)
row.addArrangedSubview(label)
row.addArrangedSubview(withdrawablePointsLabel)
let inputContainer = UIView()
inputContainer.layer.cornerRadius = 4
inputContainer.layer.borderWidth = 1
inputContainer.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
pointsInputField.placeholder = "请输入积分"
pointsInputField.keyboardType = .numberPad
pointsInputField.font = .systemFont(ofSize: 14)
let allButton = UIButton(type: .system)
allButton.setTitle("全部提现", for: .normal)
allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal)
allButton.titleLabel?.font = .systemFont(ofSize: 14)
allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside)
inputContainer.addSubview(pointsInputField)
inputContainer.addSubview(allButton)
pointsInputField.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.leading.equalToSuperview().offset(12)
make.trailing.equalTo(allButton.snp.leading).offset(-8)
}
allButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
}
inputContainer.snp.makeConstraints { make in make.height.equalTo(46) }
let ratio = makeBody("积分金额兑换比例12积分=1元人民币", color: UIColor(hex: 0xEF4444))
ratio.font = .systemFont(ofSize: 12)
let stack = UIStackView(arrangedSubviews: [title, row, inputContainer, ratio])
stack.axis = .vertical
stack.spacing = 12
inputCard.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
return inputCard
}
private func makeRecordCard() -> UIView {
recordCard.backgroundColor = .white
recordCard.layer.cornerRadius = 12
recordCard.clipsToBounds = true
let title = makeTitle("积分提现记录", size: 14)
title.textColor = UIColor(hex: 0x0073FF)
title.textAlignment = .center
recordCard.addSubview(title)
recordCard.addSubview(tableView)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(16)
}
return recordCard
}
private func makeTitle(_ text: String, size: CGFloat) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: size, weight: .medium)
label.textColor = .black
return label
}
private func makeBody(_ text: String, color: UIColor, weight: UIFont.Weight = .regular) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 14, weight: weight)
label.textColor = color
return label
}
private func showMessageIfNeeded(_ message: String?) {
guard let message, !message.isEmpty, message != lastShownMessage else { return }
lastShownMessage = message
showToast(message)
}
@objc private func pointsInputChanged() {
viewModel.updatePointsInput(pointsInputField.text ?? "")
}
@objc private func withdrawAllTapped() {
viewModel.withdrawAll()
}
@objc private func refreshPulled() {
Task { await viewModel.loadInitial(api: walletAPI) }
}
@objc private func submitTapped() {
Task {
showLoading()
await viewModel.applyWithdraw(api: walletAPI)
hideLoading()
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task { await viewModel.loadMoreWithdrawListIfNeeded(currentIndex: indexPath.row, api: walletAPI) }
}
}
/// Cell
private final class PointWithdrawRecordCell: UITableViewCell {
static let reuseIdentifier = "PointWithdrawRecordCell"
private let cardView = UIView()
private let pointsLabel = UILabel()
private let amountLabel = UILabel()
private let timeLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .bar)
private let stepsStack = UIStackView()
private let infoLabel = UILabel()
private let statusLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(_ record: PointWithdrawItem) {
pointsLabel.text = "-\(record.points)积分"
amountLabel.text = record.amount != nil && record.status == 2 ? "+\(formatAmount(record.amount))" : ""
amountLabel.isHidden = amountLabel.text?.isEmpty ?? true
timeLabel.text = record.createdAt.isEmpty ? "----" : record.createdAt
let style = statusStyle(for: record.status)
statusLabel.text = statusText(for: record.status)
statusLabel.textColor = style.text
statusLabel.backgroundColor = style.background
let progress = progress(for: record.status)
progressView.progress = Float(progress) / 100.0
progressView.progressTintColor = style.progress
progressView.isHidden = progress == 0
stepsStack.isHidden = progress == 0
configureSteps(progress: progress, tint: style.progress)
infoLabel.text = infoText(for: record)
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.layer.borderWidth = 1
cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor
cardView.clipsToBounds = true
pointsLabel.font = .systemFont(ofSize: 18, weight: .medium)
pointsLabel.textColor = .black
amountLabel.font = .systemFont(ofSize: 18, weight: .medium)
amountLabel.textColor = UIColor(hex: 0xEF4444)
amountLabel.textAlignment = .right
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = UIColor(hex: 0x7B8EAA)
progressView.trackTintColor = UIColor(hex: 0xF2F4F8)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
stepsStack.axis = .horizontal
stepsStack.distribution = .equalSpacing
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
infoLabel.font = .systemFont(ofSize: 14)
infoLabel.textColor = UIColor(hex: 0x4B5563)
infoLabel.numberOfLines = 0
contentView.addSubview(cardView)
[pointsLabel, amountLabel, timeLabel, progressView, stepsStack, infoLabel, statusLabel].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0))
}
pointsLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
}
amountLabel.snp.makeConstraints { make in
make.centerY.equalTo(pointsLabel)
make.leading.greaterThanOrEqualTo(pointsLabel.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(pointsLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
progressView.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(6)
}
stepsStack.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
infoLabel.snp.makeConstraints { make in
make.top.equalTo(stepsStack.snp.bottom).offset(10)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
make.bottom.equalToSuperview().inset(14)
}
statusLabel.snp.makeConstraints { make in
make.centerY.equalTo(infoLabel)
make.trailing.equalToSuperview().inset(16)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(70)
}
}
private func configureSteps(progress: Int, tint: UIColor) {
stepsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 11)
label.textColor = progress >= Int((Float(index) / 3.0) * 100) ? tint : UIColor(hex: 0x7B8EAA)
stepsStack.addArrangedSubview(label)
}
let percent = UILabel()
percent.text = "\(progress)%"
percent.font = .systemFont(ofSize: 12, weight: .medium)
percent.textColor = UIColor(hex: 0x9CA3AF)
stepsStack.addArrangedSubview(percent)
}
private func statusText(for status: Int) -> String {
switch status {
case 2: "提现成功"
case 1: "提现中"
case 3: "提现失败"
default: "未知"
}
}
private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) {
switch status {
case 2:
(UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0x1677FF))
case 1:
(UIColor(hex: 0xFFF0E2), UIColor(hex: 0xFF7B00), UIColor(hex: 0x1677FF))
case 3:
(UIColor(hex: 0xFFE7E7), UIColor(hex: 0xEF4444), UIColor(hex: 0xFF4D4F))
default:
(UIColor(hex: 0xE6F1FF), UIColor(hex: 0x1677FF), UIColor(hex: 0x1677FF))
}
}
private func progress(for status: Int) -> Int {
switch status {
case 1: 40
case 3: 100
default: 0
}
}
private func infoText(for record: PointWithdrawItem) -> String {
switch record.status {
case 3:
let remark = record.rejectReason.isEmpty ? "" : "\n备注信息:\(record.rejectReason)"
return "审核时间:\(record.reviewedAt.isEmpty ? "暂无" : record.reviewedAt)\(remark)"
case 2:
return "到账时间:\(record.paymentTime.isEmpty ? "暂无" : record.paymentTime)"
default:
return "预计到账时间:\(record.estimatedReceiptTime.isEmpty ? "暂无" : record.estimatedReceiptTime)"
}
}
private func formatAmount(_ value: Double?) -> String {
guard let value else { return "¥0.00" }
return String(format: "¥%.2f", value)
}
}