Files
suixinkan_uikit/suixinkan/UI/PunchPoint/PunchPointListViewController.swift

507 lines
20 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.

//
// PunchPointListViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `PunchPointListScreen`
final class PunchPointListViewController: BaseViewController {
private let viewModel: PunchPointListViewModel
private let api: any PunchPointAPIProtocol
private let filterContainer = UIView()
private let filterButton = UIButton(type: .system)
private let filterTitleLabel = UILabel()
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
private let filterDropdownView = UIView()
private let filterDropdownStack = UIStackView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let bottomBar = UIView()
private let addButton = UIButton(type: .system)
private let emptyView = PunchPointEmptyView()
private var dataSource: UITableViewDiffableDataSource<Int, PunchPointItem>!
private var filterButtons: [(PunchPointFilterType, UIButton)] = []
private var needsRefreshOnAppear = false
///
init(
viewModel: PunchPointListViewModel = PunchPointListViewModel(),
api: (any PunchPointAPIProtocol)? = nil
) {
self.viewModel = viewModel
self.api = api ?? NetworkServices.shared.punchPointAPI
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
filterContainer.backgroundColor = .white
filterButton.backgroundColor = UIColor(hex: 0xF5F5F5)
filterButton.layer.cornerRadius = 8
filterTitleLabel.text = PunchPointFilterType.all.title
filterTitleLabel.font = .systemFont(ofSize: 14)
filterTitleLabel.textColor = AppColor.textPrimary
filterChevronView.tintColor = AppColor.textSecondary
filterChevronView.contentMode = .scaleAspectFit
filterDropdownView.backgroundColor = .white
filterDropdownView.layer.cornerRadius = 8
filterDropdownView.layer.shadowColor = UIColor.black.cgColor
filterDropdownView.layer.shadowOpacity = 0.12
filterDropdownView.layer.shadowRadius = 10
filterDropdownView.layer.shadowOffset = CGSize(width: 0, height: 4)
filterDropdownView.isHidden = true
filterDropdownStack.axis = .vertical
tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none
tableView.delegate = self
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 210
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
tableView.refreshControl = UIRefreshControl()
tableView.register(PunchPointListCell.self, forCellReuseIdentifier: PunchPointListCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Int, PunchPointItem>(tableView: tableView) { [weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: PunchPointListCell.reuseIdentifier,
for: indexPath
) as! PunchPointListCell
cell.apply(item: item)
cell.onQRCode = { [weak self] in self?.viewModel.showQRCode(for: item) }
cell.onDelete = { [weak self] in self?.confirmDelete(item) }
return cell
}
bottomBar.backgroundColor = .white
addButton.setTitle("添加", for: .normal)
addButton.setTitleColor(.white, for: .normal)
addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8
emptyView.isHidden = true
emptyView.onAction = { [weak self] in self?.addTapped() }
view.addSubview(filterContainer)
filterContainer.addSubview(filterButton)
filterButton.addSubview(filterTitleLabel)
filterButton.addSubview(filterChevronView)
view.addSubview(tableView)
view.addSubview(emptyView)
view.addSubview(bottomBar)
bottomBar.addSubview(addButton)
view.addSubview(filterDropdownView)
filterDropdownView.addSubview(filterDropdownStack)
configureFilterDropdown()
}
override func setupConstraints() {
filterContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalTo(64)
}
filterButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(15)
make.centerY.equalToSuperview()
make.height.equalTo(48)
}
filterTitleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
}
filterChevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.size.equalTo(22)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
addButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
emptyView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalTo(tableView)
make.leading.trailing.equalToSuperview().inset(24)
}
filterDropdownView.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom)
make.leading.trailing.equalTo(filterButton)
}
filterDropdownStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onShowQRCode = { [weak self] url in
Task { @MainActor in self?.showQRCodeDialog(url: url) }
}
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadInitial(api: api) }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if needsRefreshOnAppear {
needsRefreshOnAppear = false
Task { await viewModel.refresh(api: api) }
}
}
@MainActor
private func applyViewModel() {
filterTitleLabel.text = viewModel.filterType.title
filterButtons.forEach { type, button in
let selected = type == viewModel.filterType
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .medium : .regular)
}
var snapshot = NSDiffableDataSourceSnapshot<Int, PunchPointItem>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.items)
dataSource.apply(snapshot, animatingDifferences: true)
tableView.refreshControl?.endRefreshing()
emptyView.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing
viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading()
}
private func configureFilterDropdown() {
filterDropdownStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
filterButtons = PunchPointFilterType.allCases.map { type in
let button = UIButton(type: .system)
button.contentHorizontalAlignment = .left
button.setTitle(type.title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14)
var configuration = UIButton.Configuration.plain()
configuration.contentInsets = NSDirectionalEdgeInsets(top: 13, leading: 12, bottom: 13, trailing: 12)
button.configuration = configuration
button.addAction(UIAction { [weak self] _ in
guard let self else { return }
self.filterDropdownView.isHidden = true
Task { await self.viewModel.selectFilter(type, api: self.api) }
}, for: .touchUpInside)
filterDropdownStack.addArrangedSubview(button)
return (type, button)
}
}
private func confirmDelete(_ item: PunchPointItem) {
let alert = UIAlertController(
title: "删除打卡点",
message: "确定删除\(item.name)打卡点吗?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.delete(item: item, api: self.api) }
})
present(alert, animated: true)
}
private func showQRCodeDialog(url: String) {
let controller = UIAlertController(title: nil, message: "\n\n\n\n\n\n\n\n", preferredStyle: .alert)
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 12
if let imageURL = URL(string: url) {
imageView.kf.setImage(with: imageURL)
}
controller.view.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalToSuperview().offset(52)
make.size.equalTo(180)
}
controller.addAction(UIAlertAction(title: "确定", style: .default))
present(controller, animated: true)
}
@objc private func filterTapped() {
filterDropdownView.isHidden.toggle()
}
@objc private func refreshPulled() {
Task { await viewModel.refresh(api: api) }
}
@objc private func addTapped() {
let controller = PunchPointFormViewController(mode: .create)
controller.onSubmitSuccess = { [weak self] in self?.needsRefreshOnAppear = true }
navigationController?.pushViewController(controller, animated: true)
}
}
extension PunchPointListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
let controller = PunchPointDetailViewController(punchPointId: item.id)
controller.onChanged = { [weak self] in self?.needsRefreshOnAppear = true }
navigationController?.pushViewController(controller, animated: true)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row, api: api) }
}
}
///
/// Android
private final class PunchPointListCell: UITableViewCell {
static let reuseIdentifier = "PunchPointListCell"
private let cardView = UIView()
private let coverImageView = UIImageView()
private let nameLabel = UILabel()
private let operatingTag = PunchPointStatusChip()
private let creatorLabel = UILabel()
private let createdAtLabel = UILabel()
private let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
private let addressLabel = UILabel()
private let reviewTag = PunchPointStatusChip()
private let auditTimeLabel = UILabel()
private let qrButton = UIButton(type: .system)
private let deleteButton = UIButton(type: .system)
var onQRCode: (() -> Void)?
var onDelete: (() -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
contentView.backgroundColor = .clear
selectionStyle = .none
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.clipsToBounds = true
coverImageView.contentMode = .scaleAspectFill
coverImageView.clipsToBounds = true
coverImageView.layer.cornerRadius = 12
coverImageView.backgroundColor = UIColor(hex: 0xF5F5F5)
nameLabel.font = .systemFont(ofSize: 16, weight: .semibold)
nameLabel.textColor = .black
nameLabel.lineBreakMode = .byTruncatingTail
[creatorLabel, createdAtLabel, auditTimeLabel].forEach {
$0.font = .systemFont(ofSize: 12)
$0.textColor = AppColor.textSecondary
}
locationIcon.tintColor = UIColor(hex: 0x4B5563)
addressLabel.font = .systemFont(ofSize: 12)
addressLabel.textColor = UIColor(hex: 0x4B5563)
addressLabel.numberOfLines = 2
qrButton.setImage(UIImage(systemName: "qrcode"), for: .normal)
qrButton.tintColor = AppColor.textPrimary
deleteButton.setImage(UIImage(systemName: "trash"), for: .normal)
deleteButton.tintColor = AppColor.textPrimary
contentView.addSubview(cardView)
[coverImageView, nameLabel, operatingTag, creatorLabel, createdAtLabel, locationIcon, addressLabel, reviewTag, auditTimeLabel, qrButton, deleteButton].forEach(cardView.addSubview)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(6)
make.leading.trailing.equalToSuperview().inset(15)
}
coverImageView.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
make.size.equalTo(128)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(coverImageView)
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
make.trailing.lessThanOrEqualTo(operatingTag.snp.leading).offset(-8)
}
operatingTag.snp.makeConstraints { make in
make.centerY.equalTo(nameLabel)
make.trailing.equalToSuperview().inset(16)
}
creatorLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(8)
make.leading.equalTo(nameLabel)
make.trailing.equalToSuperview().inset(16)
}
createdAtLabel.snp.makeConstraints { make in
make.top.equalTo(creatorLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(creatorLabel)
}
locationIcon.snp.makeConstraints { make in
make.top.equalTo(createdAtLabel.snp.bottom).offset(8)
make.leading.equalTo(nameLabel)
make.size.equalTo(14)
}
addressLabel.snp.makeConstraints { make in
make.top.equalTo(locationIcon).offset(-1)
make.leading.equalTo(locationIcon.snp.trailing).offset(4)
make.trailing.equalToSuperview().inset(16)
}
reviewTag.snp.makeConstraints { make in
make.top.equalTo(coverImageView.snp.bottom).offset(14)
make.leading.equalToSuperview().offset(16)
make.bottom.equalToSuperview().inset(16)
}
auditTimeLabel.snp.makeConstraints { make in
make.centerY.equalTo(reviewTag)
make.leading.equalTo(reviewTag.snp.trailing).offset(8)
}
deleteButton.snp.makeConstraints { make in
make.centerY.equalTo(reviewTag)
make.trailing.equalToSuperview().inset(16)
make.size.equalTo(32)
}
qrButton.snp.makeConstraints { make in
make.centerY.equalTo(reviewTag)
make.trailing.equalTo(deleteButton.snp.leading).offset(-4)
make.size.equalTo(32)
}
qrButton.addTarget(self, action: #selector(qrTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: PunchPointItem) {
nameLabel.text = item.name
creatorLabel.text = "负责人:\(item.creator?.nonEmptyTrimmed ?? "--")"
createdAtLabel.text = "创建时间:\(item.createdAt)"
addressLabel.text = item.displayAddress.nonEmptyTrimmed ?? "--"
auditTimeLabel.text = "审核时间:\(item.auditTime?.nonEmptyTrimmed ?? "--")"
operatingTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status)
reviewTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status)
if let url = item.guideImages.first.flatMap(URL.init(string:)) {
coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
} else {
coverImageView.image = UIImage(systemName: "photo")
}
}
@objc private func qrTapped() {
onQRCode?()
}
@objc private func deleteTapped() {
onDelete?()
}
}
///
///
final class PunchPointStatusChip: UIView {
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 10
clipsToBounds = true
label.font = .systemFont(ofSize: 12, weight: .medium)
label.textAlignment = .center
addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(text: String, status: Int) {
label.text = text
let colors = PunchPointDisplayFormatter.statusColors(status)
backgroundColor = UIColor(hex: colors.background)
label.textColor = UIColor(hex: colors.text)
}
}
///
///
private final class PunchPointEmptyView: UIView {
private let titleLabel = UILabel()
private let button = UIButton(type: .system)
var onAction: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.text = "暂无打卡点"
titleLabel.font = .systemFont(ofSize: 15)
titleLabel.textColor = AppColor.textSecondary
titleLabel.textAlignment = .center
button.setTitle("添加打卡点", for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
button.backgroundColor = AppColor.primary
button.layer.cornerRadius = 8
addSubview(titleLabel)
addSubview(button)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
button.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.centerX.bottom.equalToSuperview()
make.width.equalTo(128)
make.height.equalTo(40)
}
button.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func actionTapped() {
onAction?()
}
}
private extension String {
var nonEmptyTrimmed: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}