// // 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(named: "punch_point_dropdown")) 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! 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 = "打卡点列表" navigationController?.navigationBar.titleTextAttributes = [ .font: UIFont.systemFont(ofSize: 18, weight: .regular), .foregroundColor: UIColor(hex: 0x333333), ] } override func setupUI() { view.backgroundColor = UIColor(hex: 0xF4F4F4) filterContainer.backgroundColor = .white filterButton.backgroundColor = UIColor(hex: 0xF5F5F5) filterButton.layer.cornerRadius = 8 filterButton.accessibilityLabel = "筛选打卡点状态" filterTitleLabel.text = PunchPointFilterType.all.title filterTitleLabel.font = .systemFont(ofSize: 14) filterTitleLabel.textColor = AppColor.textPrimary 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 = UIColor(hex: 0xF4F4F4) 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(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 addButton.accessibilityLabel = "添加打卡点" 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) } } viewModel.onBeginDeleteAnimation = { [weak self] id in Task { @MainActor in self?.animateRemoval(id: id) } } 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() 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 = PunchPointMaterialDialogController.confirmation( title: "删除打卡点", message: "确定删除\(item.name)打卡点吗?", confirmTitle: "确定", onConfirm: { [weak self] in self?.dismiss(animated: true) guard let self else { return } Task { await self.viewModel.delete(item: item, api: self.api) } } ) { [weak self] in self?.dismiss(animated: true) } present(alert, animated: true) } @MainActor private func animateRemoval(id: Int64) { guard let index = viewModel.items.firstIndex(where: { $0.id == id }) else { viewModel.completeDeleteAnimation(id: id) return } let indexPath = IndexPath(row: index, section: 0) guard let cell = tableView.cellForRow(at: indexPath) else { viewModel.completeDeleteAnimation(id: id) return } UIView.animate( withDuration: 0.3, delay: 0, options: [.curveEaseIn, .allowUserInteraction] ) { cell.contentView.transform = CGAffineTransform(translationX: self.tableView.bounds.width, y: 0) cell.contentView.alpha = 0 } completion: { [weak self] _ in guard let self else { return } self.viewModel.completeDeleteAnimation(id: id) } } private func showQRCodeDialog(url: String) { let controller = PunchPointMaterialDialogController.qrCode(url: url) { [weak self] in self?.dismiss(animated: true) } 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(named: "punch_point_location")) 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.contentMode = .scaleAspectFit addressLabel.font = .systemFont(ofSize: 12) addressLabel.textColor = UIColor(hex: 0x4B5563) addressLabel.numberOfLines = 2 qrButton.setImage(UIImage(named: "punch_point_qr"), for: .normal) deleteButton.setImage(UIImage(named: "punch_point_delete"), for: .normal) qrButton.accessibilityLabel = "查看二维码" deleteButton.accessibilityLabel = "删除打卡点" 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.equalToSuperview().offset(14) make.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) { contentView.transform = .identity contentView.alpha = 1 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(PunchPointDisplayFormatter.operatingStatus(item.status), compact: true) reviewTag.apply(PunchPointDisplayFormatter.auditStatus(item.status), compact: false) if let url = item.guideImages.first.flatMap(URL.init(string:)) { coverImageView.kf.setImage(with: url, placeholder: UIImage(named: "SplashLogo")) } else { coverImageView.image = UIImage(named: "SplashLogo") } } @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 = 4 clipsToBounds = true label.font = .systemFont(ofSize: 12) label.textAlignment = .center addSubview(label) label.snp.makeConstraints { make in make.edges.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 应用标签文案和状态色。 func apply(_ presentation: PunchPointStatusPresentation, compact: Bool = false) { label.text = presentation.title backgroundColor = UIColor(hex: presentation.backgroundColor) label.textColor = UIColor(hex: presentation.textColor) label.snp.remakeConstraints { make in make.edges.equalToSuperview().inset( UIEdgeInsets(top: compact ? 2 : 4, left: compact ? 8 : 10, bottom: compact ? 2 : 4, right: compact ? 8 : 10) ) } } } /// 打卡点列表空状态。 /// 打卡点空列表视图,提供空状态文案与新增入口。 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: 14) 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.greaterThanOrEqualTo(96) 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?() } } /// Android Material3 风格的打卡点确认与二维码弹窗。 private final class PunchPointMaterialDialogController: UIViewController { private enum Content { case confirmation(title: String, message: String, confirmTitle: String) case qrCode(url: String) } private let content: Content private let onConfirm: () -> Void private let onCancel: (() -> Void)? private init(content: Content, onConfirm: @escaping () -> Void, onCancel: (() -> Void)? = nil) { self.content = content self.onConfirm = onConfirm self.onCancel = onCancel super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen modalTransitionStyle = .crossDissolve } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } static func confirmation( title: String, message: String, confirmTitle: String, onConfirm: @escaping () -> Void, onCancel: @escaping () -> Void ) -> PunchPointMaterialDialogController { PunchPointMaterialDialogController( content: .confirmation(title: title, message: message, confirmTitle: confirmTitle), onConfirm: onConfirm, onCancel: onCancel ) } static func qrCode(url: String, onConfirm: @escaping () -> Void) -> PunchPointMaterialDialogController { PunchPointMaterialDialogController(content: .qrCode(url: url), onConfirm: onConfirm) } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor.black.withAlphaComponent(0.32) view.accessibilityViewIsModal = true let dialog = UIView() dialog.backgroundColor = .white dialog.layer.cornerRadius = 28 dialog.clipsToBounds = true view.addSubview(dialog) dialog.snp.makeConstraints { make in make.center.equalToSuperview() make.leading.greaterThanOrEqualToSuperview().offset(32) make.trailing.lessThanOrEqualToSuperview().offset(-32) make.width.lessThanOrEqualTo(320) } switch content { case let .confirmation(title, message, confirmTitle): configureConfirmation(dialog: dialog, title: title, message: message, confirmTitle: confirmTitle) case let .qrCode(url): configureQRCode(dialog: dialog, url: url) } } private func configureConfirmation(dialog: UIView, title: String, message: String, confirmTitle: String) { let titleLabel = UILabel() titleLabel.text = title titleLabel.font = .systemFont(ofSize: 16, weight: .medium) titleLabel.textColor = UIColor(hex: 0x333333) let messageLabel = UILabel() messageLabel.text = message messageLabel.font = .systemFont(ofSize: 14) messageLabel.textColor = UIColor(hex: 0x666666) messageLabel.numberOfLines = 0 let cancel = textButton(title: "取消", color: UIColor(hex: 0x666666), action: #selector(cancelTapped)) let confirm = textButton(title: confirmTitle, color: AppColor.primary, action: #selector(confirmTapped)) let actions = UIStackView(arrangedSubviews: [UIView(), cancel, confirm]) actions.axis = .horizontal actions.spacing = 8 [titleLabel, messageLabel, actions].forEach(dialog.addSubview) titleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(24) make.leading.trailing.equalToSuperview().inset(24) } messageLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(16) make.leading.trailing.equalToSuperview().inset(24) } actions.snp.makeConstraints { make in make.top.equalTo(messageLabel.snp.bottom).offset(16) make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalToSuperview().inset(12) make.height.equalTo(44) } } private func configureQRCode(dialog: UIView, url: String) { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.cornerRadius = 12 if let imageURL = URL(string: url) { imageView.kf.setImage(with: imageURL) } let confirm = textButton(title: "确定", color: AppColor.primary, action: #selector(confirmTapped)) dialog.addSubview(imageView) dialog.addSubview(confirm) imageView.snp.makeConstraints { make in make.top.equalToSuperview().offset(32) make.centerX.equalToSuperview() make.size.equalTo(180) make.leading.greaterThanOrEqualToSuperview().offset(32) } confirm.snp.makeConstraints { make in make.top.equalTo(imageView.snp.bottom).offset(12) make.trailing.equalToSuperview().inset(16) make.bottom.equalToSuperview().inset(12) make.width.equalTo(64) make.height.equalTo(44) } } private func textButton(title: String, color: UIColor, action: Selector) -> UIButton { let button = UIButton(type: .system) button.setTitle(title, for: .normal) button.setTitleColor(color, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) button.addTarget(self, action: action, for: .touchUpInside) return button } @objc private func confirmTapped() { onConfirm() } @objc private func cancelTapped() { onCancel?() } } private extension String { var nonEmptyTrimmed: String? { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? nil : text } }