完善首页权限申请流程与队列播报体验。

对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 13:46:45 +08:00
parent c083f1d4b3
commit efb3925257
50 changed files with 2585 additions and 455 deletions

View File

@ -0,0 +1,360 @@
//
// PermissionDropdownViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
enum PermissionDropdownMode {
case roles([RoleOption])
case scenics([ScenicOption], isLoading: Bool)
}
/// Android
final class PermissionDropdownViewController: UIViewController, UITableViewDelegate, UITextFieldDelegate {
private enum Section { case main }
fileprivate enum Item: Hashable {
case role(RoleOption)
case scenic(ScenicOption)
case loading
case empty(String)
}
var onDismiss: (() -> Void)?
var onRoleSelect: ((RoleOption) -> Void)?
var onScenicToggle: ((ScenicOption) -> Void)?
var onSearchChange: ((String) -> Void)?
private let anchorFrame: CGRect
private var mode: PermissionDropdownMode
private var allScenics: [ScenicOption] = []
private var query = ""
private let dismissControl = UIControl()
private let cardView = UIView()
private let searchContainer = UIView()
private let searchField = UITextField()
private let tableView = UITableView(frame: .zero, style: .plain)
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var cardHeightConstraint: Constraint?
///
init(anchor: UIView, mode: PermissionDropdownMode) {
anchorFrame = anchor.convert(anchor.bounds, to: nil)
self.mode = mode
if case .scenics(let scenics, _) = mode {
allScenics = scenics
}
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
dismissControl.backgroundColor = .clear
dismissControl.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 4
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.14
cardView.layer.shadowRadius = 8
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
cardView.clipsToBounds = false
searchContainer.backgroundColor = .white
searchContainer.layer.cornerRadius = 8
searchContainer.layer.borderWidth = 1
searchContainer.layer.borderColor = PermissionApplyUITokens.selectorBorder.cgColor
let searchIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
searchIcon.tintColor = AppColor.textTertiary
searchIcon.contentMode = .scaleAspectFit
searchField.placeholder = "搜索景区名称"
searchField.font = .systemFont(ofSize: 14)
searchField.textColor = PermissionApplyUITokens.text
searchField.returnKeyType = .search
searchField.clearButtonMode = .whileEditing
searchField.delegate = self
searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged)
searchField.accessibilityLabel = "搜索景区名称"
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.keyboardDismissMode = .onDrag
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 52
tableView.delegate = self
tableView.register(PermissionDropdownCell.self, forCellReuseIdentifier: PermissionDropdownCell.reuseIdentifier)
view.addSubview(dismissControl)
view.addSubview(cardView)
cardView.addSubview(searchContainer)
searchContainer.addSubview(searchIcon)
searchContainer.addSubview(searchField)
cardView.addSubview(tableView)
dismissControl.snp.makeConstraints { make in make.edges.equalToSuperview() }
cardView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(anchorFrame.minX)
make.top.equalToSuperview().offset(anchorFrame.maxY + 4)
make.width.equalTo(anchorFrame.width)
cardHeightConstraint = make.height.equalTo(80).constraint
}
searchContainer.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.equalToSuperview().inset(12)
make.height.equalTo(38)
}
searchIcon.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
searchField.snp.makeConstraints { make in
make.leading.equalTo(searchIcon.snp.trailing).offset(10)
make.trailing.equalToSuperview().offset(-8)
make.top.bottom.equalToSuperview()
}
tableView.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.top.equalTo(searchContainer.snp.bottom).offset(4)
}
configureDataSource()
applyMode(animated: false)
}
///
func updateScenics(_ scenics: [ScenicOption], isLoading: Bool) {
allScenics = scenics
mode = .scenics(scenics, isLoading: isLoading)
guard isViewLoaded else { return }
applyMode(animated: false)
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) {
tableView, indexPath, item in
guard let cell = tableView.dequeueReusableCell(
withIdentifier: PermissionDropdownCell.reuseIdentifier,
for: indexPath
) as? PermissionDropdownCell else { return UITableViewCell() }
cell.apply(item: item)
return cell
}
}
private func applyMode(animated: Bool) {
var items: [Item]
switch mode {
case .roles(let roles):
searchContainer.isHidden = true
tableView.snp.remakeConstraints { make in make.edges.equalToSuperview() }
items = roles.map(Item.role)
if items.isEmpty { items = [.empty("暂无可用角色")] }
case .scenics(_, let isLoading):
searchContainer.isHidden = isLoading
if isLoading {
tableView.snp.remakeConstraints { make in make.edges.equalToSuperview() }
items = [.loading]
} else {
tableView.snp.remakeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.top.equalTo(searchContainer.snp.bottom).offset(4)
}
let filtered = filteredScenics()
items = filtered.map(Item.scenic)
if items.isEmpty {
items = [.empty(query.isEmpty ? "暂无可用景区" : "未找到相关景区")]
}
}
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
let completion: () -> Void = { [weak self] in
guard let self else { return }
self.updateCardHeight(itemCount: items.count)
}
if animated {
dataSource.apply(snapshot, animatingDifferences: true, completion: completion)
} else {
UIView.performWithoutAnimation {
dataSource.apply(snapshot, animatingDifferences: false, completion: completion)
tableView.layoutIfNeeded()
}
}
}
private func filteredScenics() -> [ScenicOption] {
let value = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return allScenics }
return allScenics.filter { $0.name.localizedCaseInsensitiveContains(value) }
}
private func updateCardHeight(itemCount: Int) {
tableView.layoutIfNeeded()
let hasSearch = !searchContainer.isHidden
let searchHeight: CGFloat = hasSearch ? 58 : 0
let estimatedListHeight = max(64, min(262, tableView.contentSize.height > 0 ? tableView.contentSize.height : CGFloat(itemCount) * 52))
cardHeightConstraint?.update(offset: min(320, searchHeight + estimatedListHeight))
view.layoutIfNeeded()
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .role(let role):
onRoleSelect?(role)
dismissMenu(notify: false)
case .scenic(let scenic):
guard !scenic.isDisabled else { return }
onScenicToggle?(scenic)
if let index = allScenics.firstIndex(where: { $0.id == scenic.id }) {
allScenics[index].isSelected.toggle()
}
mode = .scenics(allScenics, isLoading: false)
applyMode(animated: false)
case .loading, .empty:
break
}
}
@objc private func searchChanged() {
query = searchField.text ?? ""
onSearchChange?(query)
applyMode(animated: false)
}
@objc private func dismissTapped() {
dismissMenu(notify: true)
}
private func dismissMenu(notify: Bool) {
searchField.resignFirstResponder()
dismiss(animated: true) { [weak self] in
if notify { self?.onDismiss?() }
}
}
}
///
private final class PermissionDropdownCell: UITableViewCell {
static let reuseIdentifier = "PermissionDropdownCell"
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let checkCircle = UIView()
private let checkImage = UIImageView(image: UIImage(systemName: "checkmark"))
private let activityIndicator = UIActivityIndicatorView(style: .medium)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .white
selectionStyle = .none
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = PermissionApplyUITokens.text
titleLabel.numberOfLines = 1
detailLabel.font = .systemFont(ofSize: 12)
detailLabel.textColor = PermissionApplyUITokens.placeholder
detailLabel.numberOfLines = 0
checkCircle.frame = CGRect(x: 0, y: 0, width: 20, height: 20)
checkCircle.layer.cornerRadius = 10
checkImage.tintColor = .white
checkImage.contentMode = .scaleAspectFit
activityIndicator.color = PermissionApplyUITokens.selectedBlue
[titleLabel, detailLabel, activityIndicator].forEach(contentView.addSubview)
accessoryView = checkCircle
checkCircle.addSubview(checkImage)
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.equalToSuperview().offset(16)
make.trailing.equalToSuperview().offset(-12)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-12)
make.bottom.equalToSuperview().offset(-10)
}
checkImage.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(14) }
activityIndicator.snp.makeConstraints { make in make.center.equalToSuperview() }
contentView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(48) }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: PermissionDropdownViewController.Item) {
activityIndicator.stopAnimating()
titleLabel.textAlignment = .left
titleLabel.font = .systemFont(ofSize: 14)
detailLabel.isHidden = true
checkCircle.isHidden = true
isUserInteractionEnabled = true
switch item {
case .role(let role):
titleLabel.text = role.name
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = PermissionApplyUITokens.text
detailLabel.text = role.description
detailLabel.isHidden = role.description.isEmpty
checkCircle.isHidden = false
checkCircle.backgroundColor = role.isSelected
? PermissionApplyUITokens.selectedBlue : UIColor(hex: 0xE5E7EB)
checkImage.isHidden = !role.isSelected
case .scenic(let scenic):
titleLabel.text = scenic.name
titleLabel.textColor = scenic.isDisabled ? AppColor.textTertiary : PermissionApplyUITokens.text
checkCircle.isHidden = false
checkCircle.backgroundColor = scenic.isDisabled && scenic.isSelected
? AppColor.textTertiary
: (scenic.isSelected ? PermissionApplyUITokens.selectedBlue : UIColor(hex: 0xE5E7EB))
checkImage.isHidden = !scenic.isSelected
isUserInteractionEnabled = !scenic.isDisabled
case .loading:
titleLabel.text = nil
activityIndicator.startAnimating()
isUserInteractionEnabled = false
case .empty(let message):
titleLabel.text = message
titleLabel.textAlignment = .center
titleLabel.textColor = AppColor.textTertiary
isUserInteractionEnabled = false
}
if detailLabel.isHidden {
titleLabel.snp.remakeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview().offset(12)
make.bottom.lessThanOrEqualToSuperview().offset(-12)
}
} else {
titleLabel.snp.remakeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.equalToSuperview().offset(16)
make.trailing.equalToSuperview().offset(-12)
}
}
accessibilityLabel = [titleLabel.text, detailLabel.isHidden ? nil : detailLabel.text]
.compactMap { $0 }.joined(separator: "")
}
}