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

547 lines
21 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.

//
// WalletViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android
final class WalletViewController: BaseViewController, UITableViewDelegate {
private enum Section {
case main
}
private enum Item: Hashable {
case withdraw(WalletWithdrawItem)
case transactionHeader(EarningDetailGroup)
case transaction(EarningDetailItem)
}
private let viewModel: WalletViewModel
private let walletAPI: any WalletPageServing
private let profileAPI: any WalletProfileServing
private let summaryCard = WalletSummaryCardView()
private let contentCard = UIView()
private let tabStack = UIStackView()
private let withdrawTabButton = UIButton(type: .system)
private let transactionTabButton = UIButton(type: .system)
private let filterPanel = WalletFilterPanelView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let bottomBar = UIView()
private let withdrawButton = AppButton(title: "提现到银行卡")
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var lastShownMessage: String?
///
init(
viewModel: WalletViewModel = WalletViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI,
profileAPI: any WalletProfileServing = NetworkServices.shared.profileAPI
) {
self.viewModel = viewModel
self.walletAPI = walletAPI
self.profileAPI = profileAPI
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)
contentCard.backgroundColor = .white
contentCard.layer.cornerRadius = 16
contentCard.clipsToBounds = true
tabStack.axis = .horizontal
tabStack.distribution = .fillEqually
[withdrawTabButton, transactionTabButton].forEach {
$0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
$0.setTitleColor(UIColor(hex: 0x666666), for: .normal)
tabStack.addArrangedSubview($0)
}
withdrawTabButton.setTitle("提现记录", for: .normal)
transactionTabButton.setTitle("收益明细", for: .normal)
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.refreshControl = refreshControl
tableView.register(WalletWithdrawRecordCell.self, forCellReuseIdentifier: WalletWithdrawRecordCell.reuseIdentifier)
tableView.register(WalletTransactionHeaderCell.self, forCellReuseIdentifier: WalletTransactionHeaderCell.reuseIdentifier)
tableView.register(WalletTransactionRecordCell.self, forCellReuseIdentifier: WalletTransactionRecordCell.reuseIdentifier)
bottomBar.backgroundColor = .white
view.addSubview(summaryCard)
view.addSubview(contentCard)
contentCard.addSubview(tabStack)
contentCard.addSubview(filterPanel)
contentCard.addSubview(tableView)
view.addSubview(bottomBar)
bottomBar.addSubview(withdrawButton)
configureDataSource()
}
override func setupConstraints() {
summaryCard.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
withdrawButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22)
}
contentCard.snp.makeConstraints { make in
make.top.equalTo(summaryCard.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-16)
}
tabStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(50)
}
filterPanel.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom)
make.leading.trailing.equalToSuperview().inset(16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterPanel.snp.bottom).offset(4)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.applyState()
}
}
summaryCard.onPointsTap = { [weak self] in
self?.openPointsRedemption()
}
withdrawTabButton.addTarget(self, action: #selector(withdrawTabTapped), for: .touchUpInside)
transactionTabButton.addTarget(self, action: #selector(transactionTabTapped), for: .touchUpInside)
filterPanel.onFilterTap = { [weak self] in
self?.presentFilterMenu()
}
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
withdrawButton.addTarget(self, action: #selector(withdrawTapped), for: .touchUpInside)
Task { await viewModel.loadInitial(api: walletAPI) }
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { tableView, indexPath, item in
switch item {
case .withdraw(let record):
let cell = tableView.dequeueReusableCell(
withIdentifier: WalletWithdrawRecordCell.reuseIdentifier,
for: indexPath
) as? WalletWithdrawRecordCell
cell?.apply(record)
return cell ?? UITableViewCell()
case .transactionHeader(let group):
let cell = tableView.dequeueReusableCell(
withIdentifier: WalletTransactionHeaderCell.reuseIdentifier,
for: indexPath
) as? WalletTransactionHeaderCell
cell?.apply(group)
return cell ?? UITableViewCell()
case .transaction(let record):
let cell = tableView.dequeueReusableCell(
withIdentifier: WalletTransactionRecordCell.reuseIdentifier,
for: indexPath
) as? WalletTransactionRecordCell
cell?.apply(record)
return cell ?? UITableViewCell()
}
}
}
private func applyState() {
summaryCard.apply(
withdrawable: viewModel.summary?.amountWithdrawable,
total: viewModel.summary?.amountTotal,
balance: viewModel.summary?.amountCurrentBalance,
points: viewModel.availablePoints
)
applyTabAppearance()
filterPanel.isHidden = viewModel.selectedTab != .transaction
filterPanel.apply(filter: viewModel.filter, totalIncome: viewModel.totalIncomeLabel, totalPoints: viewModel.totalPointsLabel)
refreshControl.endRefreshing()
applySnapshot()
if let message = viewModel.errorMessage, message != lastShownMessage {
lastShownMessage = message
showToast(message)
}
}
private func applyTabAppearance() {
let selectedColor = UIColor(hex: 0x1677FF)
let normalColor = UIColor(hex: 0x666666)
withdrawTabButton.setTitleColor(viewModel.selectedTab == .withdraw ? selectedColor : normalColor, for: .normal)
transactionTabButton.setTitleColor(viewModel.selectedTab == .transaction ? selectedColor : normalColor, for: .normal)
withdrawTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .withdraw ? .semibold : .regular)
transactionTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .transaction ? .semibold : .regular)
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
if viewModel.selectedTab == .withdraw {
snapshot.appendItems(viewModel.withdrawRecords.map(Item.withdraw), toSection: .main)
} else {
let items = viewModel.transactionEntries.map { entry -> Item in
switch entry {
case .header(let group): .transactionHeader(group)
case .item(let item): .transaction(item)
}
}
snapshot.appendItems(items, toSection: .main)
}
dataSource.apply(snapshot, animatingDifferences: true)
updateEmptyState()
}
private func updateEmptyState() {
let emptyText: String?
if viewModel.selectedTab == .withdraw, viewModel.withdrawRecords.isEmpty, !viewModel.withdrawLoading {
emptyText = "暂无提现记录"
} else if viewModel.selectedTab == .transaction, viewModel.transactionEntries.isEmpty, !viewModel.transactionLoading {
emptyText = "暂无收益明细"
} else {
emptyText = nil
}
guard let emptyText else {
tableView.backgroundView = nil
return
}
let label = UILabel()
label.text = emptyText
label.textColor = UIColor(hex: 0xB3B8C2)
label.font = .systemFont(ofSize: 14)
label.textAlignment = .center
tableView.backgroundView = label
}
private func presentFilterMenu() {
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
WalletFilter.allCases.forEach { filter in
alert.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func openPointsRedemption() {
navigationController?.pushViewController(PointsRedemptionViewController(), animated: true)
}
@objc private func withdrawTabTapped() {
Task { await viewModel.selectTab(.withdraw, api: walletAPI) }
}
@objc private func transactionTabTapped() {
Task { await viewModel.selectTab(.transaction, api: walletAPI) }
}
@objc private func refreshPulled() {
Task {
switch viewModel.selectedTab {
case .withdraw:
await viewModel.refreshSummary(api: walletAPI)
await viewModel.refreshWithdraw(api: walletAPI)
await viewModel.refreshPoints(api: walletAPI)
case .transaction:
await viewModel.refreshTransaction(api: walletAPI)
}
}
}
@objc private func withdrawTapped() {
Task {
showLoading()
do {
let destination = try await viewModel.resolveWithdrawDestination(profileAPI: profileAPI)
hideLoading()
route(to: destination)
} catch {
hideLoading()
showToast(error.localizedDescription)
}
}
}
private func route(to destination: WalletWithdrawDestination) {
switch destination {
case .realNameAuth:
navigationController?.pushViewController(RealNameAuthViewController(), animated: true)
case .realNameAudit(let info):
showToast("实名认证不通过,请重新提交")
navigationController?.pushViewController(RealNameAuthAuditViewController(info: info), animated: true)
case .realNamePending:
showToast("实名认证审核中,请审核通过后再试")
case .withdrawalSettings:
navigationController?.pushViewController(WithdrawalSettingsViewController(), animated: true)
case .withdrawalAudit(let bankCard):
showToast("银行卡审核不通过,请重新提交")
navigationController?.pushViewController(WithdrawalSettingsAuditViewController(bankCard: bankCard), animated: true)
case .bankCardPending:
showToast("银行卡审核中,请审核通过后再试")
case .withdraw:
let controller = WithdrawViewController()
controller.onSubmitSuccess = { [weak self] in
guard let self else { return }
Task {
await self.viewModel.refreshSummary(api: self.walletAPI)
await self.viewModel.refreshWithdraw(api: self.walletAPI)
}
}
navigationController?.pushViewController(controller, animated: true)
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task {
switch viewModel.selectedTab {
case .withdraw:
await viewModel.loadMoreWithdrawIfNeeded(currentIndex: indexPath.row, api: walletAPI)
case .transaction:
await viewModel.loadMoreTransactionIfNeeded(currentIndex: indexPath.row, api: walletAPI)
}
}
}
}
///
private final class WalletSummaryCardView: UIView {
var onPointsTap: (() -> Void)?
private let withdrawableTitle = UILabel()
private let withdrawableLabel = UILabel()
private let totalView = WalletSummaryMetricView()
private let balanceView = WalletSummaryMetricView()
private let pointsView = WalletSummaryMetricView(showsArrow: true)
private let metricsStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(withdrawable: String?, total: String?, balance: String?, points: Int) {
withdrawableLabel.text = WalletViewModel.formatAmount(withdrawable)
totalView.apply(title: "累计金额", value: WalletViewModel.formatAmount(total))
balanceView.apply(title: "当前余额", value: WalletViewModel.formatAmount(balance))
pointsView.apply(title: "当前积分", value: "\(points)")
}
private func setupUI() {
backgroundColor = UIColor(hex: 0x1677FF)
layer.cornerRadius = 24
clipsToBounds = true
withdrawableTitle.text = "可提现金额"
withdrawableTitle.textColor = UIColor.white.withAlphaComponent(0.8)
withdrawableTitle.font = .systemFont(ofSize: 14)
withdrawableTitle.textAlignment = .center
withdrawableLabel.textColor = .white
withdrawableLabel.font = .systemFont(ofSize: 36, weight: .bold)
withdrawableLabel.textAlignment = .center
withdrawableLabel.adjustsFontSizeToFitWidth = true
withdrawableLabel.minimumScaleFactor = 0.7
metricsStack.axis = .horizontal
metricsStack.distribution = .fillEqually
[totalView, balanceView, pointsView].forEach(metricsStack.addArrangedSubview)
pointsView.addTarget(self, action: #selector(pointsTapped), for: .touchUpInside)
addSubview(withdrawableTitle)
addSubview(withdrawableLabel)
addSubview(metricsStack)
withdrawableTitle.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(24)
}
withdrawableLabel.snp.makeConstraints { make in
make.top.equalTo(withdrawableTitle.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(24)
}
metricsStack.snp.makeConstraints { make in
make.top.equalTo(withdrawableLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(12)
make.bottom.equalToSuperview().inset(16)
}
}
@objc private func pointsTapped() {
onPointsTap?()
}
}
///
private final class WalletSummaryMetricView: UIControl {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let arrowLabel = UILabel()
private let showsArrow: Bool
init(showsArrow: Bool = false) {
self.showsArrow = showsArrow
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String, value: String) {
titleLabel.text = title
valueLabel.text = value
}
private func setupUI() {
titleLabel.textColor = UIColor.white.withAlphaComponent(0.8)
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textAlignment = .center
valueLabel.textColor = .white
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.textAlignment = .center
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.75
arrowLabel.text = showsArrow ? "" : ""
arrowLabel.textColor = .white
arrowLabel.font = .systemFont(ofSize: 20, weight: .medium)
let valueStack = UIStackView(arrangedSubviews: [valueLabel, arrowLabel])
valueStack.axis = .horizontal
valueStack.alignment = .center
valueStack.spacing = 2
valueStack.distribution = .fill
addSubview(titleLabel)
addSubview(valueStack)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
valueStack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(6)
make.centerX.equalToSuperview()
make.leading.greaterThanOrEqualToSuperview()
make.trailing.lessThanOrEqualToSuperview()
make.bottom.equalToSuperview()
}
}
}
///
private final class WalletFilterPanelView: UIView {
var onFilterTap: (() -> Void)?
private let filterButton = UIButton(type: .system)
private let totalTitleLabel = UILabel()
private let totalLabel = UILabel()
private let pointsLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(filter: WalletFilter, totalIncome: String, totalPoints: Int) {
filterButton.setTitle("\(filter.title)", for: .normal)
totalLabel.text = totalIncome.isEmpty ? "¥ 0.00" : totalIncome
pointsLabel.text = "积分+\(totalPoints)"
}
private func setupUI() {
backgroundColor = UIColor(hex: 0xF6F6F6)
layer.cornerRadius = 16
clipsToBounds = true
filterButton.setTitleColor(UIColor(hex: 0x111827), for: .normal)
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
filterButton.contentHorizontalAlignment = .left
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
totalTitleLabel.text = "总收益:"
totalTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalTitleLabel.textColor = .black
totalLabel.font = .systemFont(ofSize: 14, weight: .medium)
totalLabel.textColor = UIColor(hex: 0x0073FF)
pointsLabel.font = .systemFont(ofSize: 14, weight: .medium)
pointsLabel.textColor = UIColor(hex: 0xFF7B00)
pointsLabel.textAlignment = .right
addSubview(filterButton)
addSubview(totalTitleLabel)
addSubview(totalLabel)
addSubview(pointsLabel)
filterButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.equalToSuperview().offset(16)
make.width.lessThanOrEqualTo(150)
make.height.equalTo(32)
}
totalTitleLabel.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom).offset(4)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(12)
}
totalLabel.snp.makeConstraints { make in
make.centerY.equalTo(totalTitleLabel)
make.leading.equalTo(totalTitleLabel.snp.trailing).offset(8)
}
pointsLabel.snp.makeConstraints { make in
make.centerY.equalTo(totalTitleLabel)
make.leading.greaterThanOrEqualTo(totalLabel.snp.trailing).offset(8)
make.trailing.equalToSuperview().inset(16)
}
}
@objc private func filterTapped() {
onFilterTap?()
}
}