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

对齐 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

@ -6,11 +6,26 @@
import SnapKit
import UIKit
///
/// Android Compose
enum PermissionApplyUITokens {
static let pageBackground = UIColor(hex: 0xF5F5F5)
static let selectorBorder = UIColor(hex: 0xE0E3EA)
static let placeholder = UIColor(hex: 0xB3B8C2)
static let text = UIColor(hex: 0x111827)
static let existingBackground = UIColor(hex: 0xF8F9FB)
static let selectedBackground = UIColor(hex: 0xF4F9FF)
static let selectedBlue = UIColor(hex: 0x1677FF)
static let existingGreen = UIColor(hex: 0x52C41A)
static let existingGreenBackground = UIColor(hex: 0xE6F7E6)
static let disabledButton = UIColor(hex: 0xE0E0E0)
}
///
final class PermissionApplyBannerView: UIView {
var onApplyNewScenic: (() -> Void)?
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let applyButton = UIButton(type: .system)
@ -18,28 +33,50 @@ final class PermissionApplyBannerView: UIView {
super.init(frame: frame)
backgroundColor = AppColor.warningBackground
iconView.image = UIImage(named: "permission_open_scenic")
?? UIImage(systemName: "building.2.fill")?.withTintColor(AppColor.warning, renderingMode: .alwaysOriginal)
iconView.contentMode = .scaleAspectFit
iconView.isAccessibilityElement = false
titleLabel.text = "没有找到心仪的景区"
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = AppColor.warning
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
applyButton.setTitle("申请平台开通新景区", for: .normal)
applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
applyButton.setTitleColor(AppColor.warning, for: .normal)
var configuration = UIButton.Configuration.plain()
configuration.title = "申请平台开通新景区"
configuration.image = UIImage(named: "permission_more_scenic")
?? UIImage(systemName: "chevron.right")
configuration.imagePlacement = .trailing
configuration.imagePadding = 2
configuration.contentInsets = .zero
configuration.baseForegroundColor = AppColor.warning
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 14, weight: .medium)
return outgoing
}
applyButton.configuration = configuration
applyButton.accessibilityLabel = "申请平台开通新景区"
applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside)
addSubview(titleLabel)
addSubview(applyButton)
titleLabel.snp.makeConstraints { make in
[iconView, titleLabel, applyButton].forEach(addSubview)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(applyButton.snp.leading).offset(-12)
}
applyButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.height.greaterThanOrEqualTo(44)
}
snp.makeConstraints { make in
make.height.equalTo(44)
}
snp.makeConstraints { make in make.height.equalTo(48) }
}
@available(*, unavailable)
@ -52,7 +89,7 @@ final class PermissionApplyBannerView: UIView {
}
}
/// Android `FormField` + Selector
/// Android
final class PermissionFormSelectorRow: UIView {
var onTap: (() -> Void)?
@ -60,6 +97,7 @@ final class PermissionFormSelectorRow: UIView {
private let titleLabel = UILabel()
private let tagLabel = UILabel()
private let descriptionLabel = UILabel()
let selectorControl = UIControl()
private let valueLabel = UILabel()
private let chevron = UIImageView(image: UIImage(systemName: "chevron.down"))
@ -67,7 +105,7 @@ final class PermissionFormSelectorRow: UIView {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textColor = UIColor(hex: 0x333333)
tagLabel.text = tag
tagLabel.font = .systemFont(ofSize: 12)
@ -76,28 +114,28 @@ final class PermissionFormSelectorRow: UIView {
descriptionLabel.text = description
descriptionLabel.font = .systemFont(ofSize: 12)
descriptionLabel.textColor = UIColor(hex: 0xB3B8C2)
descriptionLabel.textColor = PermissionApplyUITokens.placeholder
descriptionLabel.numberOfLines = 0
selectorControl.backgroundColor = .white
selectorControl.layer.cornerRadius = 12
selectorControl.layer.borderWidth = 1
selectorControl.layer.borderColor = PermissionApplyUITokens.selectorBorder.cgColor
selectorControl.accessibilityLabel = title
selectorControl.addTarget(self, action: #selector(rowTapped), for: .touchUpInside)
valueLabel.text = placeholder
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = UIColor(hex: 0xB3B8C2)
valueLabel.textColor = PermissionApplyUITokens.placeholder
valueLabel.lineBreakMode = .byTruncatingTail
chevron.tintColor = UIColor(hex: 0xB3B8C2)
chevron.tintColor = PermissionApplyUITokens.placeholder
chevron.contentMode = .scaleAspectFit
let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped))
addGestureRecognizer(tap)
[titleLabel, tagLabel, descriptionLabel, selectorControl].forEach(addSubview)
[valueLabel, chevron].forEach(selectorControl.addSubview)
addSubview(titleLabel)
addSubview(tagLabel)
addSubview(descriptionLabel)
addSubview(valueLabel)
addSubview(chevron)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview() }
tagLabel.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview()
@ -107,121 +145,18 @@ final class PermissionFormSelectorRow: UIView {
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
selectorControl.snp.makeConstraints { make in
make.top.equalTo(descriptionLabel.snp.bottom).offset(8)
make.leading.bottom.equalToSuperview()
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(46)
}
valueLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8)
}
chevron.snp.makeConstraints { make in
make.trailing.equalToSuperview()
make.centerY.equalTo(valueLabel)
make.size.equalTo(14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setValue(_ text: String, isPlaceholder: Bool) {
valueLabel.text = text
valueLabel.textColor = isPlaceholder ? UIColor(hex: 0xB3B8C2) : AppColor.textPrimary
}
@objc private func rowTapped() {
onTap?()
}
}
///
final class PermissionSelectedTagsView: UIView {
private let titleLabel = UILabel()
private let stackView = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = UIColor(hex: 0x111827)
stackView.axis = .vertical
stackView.spacing = 8
addSubview(titleLabel)
addSubview(stackView)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
stackView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String, names: [String], onRemove: ((Int) -> Void)? = nil) {
titleLabel.text = title
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
guard !names.isEmpty else {
isHidden = true
return
}
isHidden = false
let row = UIStackView()
row.axis = .horizontal
row.spacing = 8
row.alignment = .leading
names.enumerated().forEach { index, name in
let chip = PermissionTagChip(title: name)
if let onRemove {
chip.onRemove = { onRemove(index) }
}
row.addArrangedSubview(chip)
}
stackView.addArrangedSubview(row)
}
}
/// Chip
final class PermissionTagChip: UIView {
var onRemove: (() -> Void)?
private let label = UILabel()
private let removeButton = UIButton(type: .system)
init(title: String, showsRemove: Bool = true) {
super.init(frame: .zero)
layer.cornerRadius = 4
layer.borderWidth = 1
layer.borderColor = AppColor.primary.cgColor
backgroundColor = .white
label.text = title
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.primary
removeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
removeButton.tintColor = AppColor.primary
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
removeButton.isHidden = !showsRemove
addSubview(label)
addSubview(removeButton)
label.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.top.bottom.equalToSuperview().inset(8)
if showsRemove {
make.trailing.equalTo(removeButton.snp.leading).offset(-4)
} else {
make.trailing.equalToSuperview().offset(-12)
}
}
removeButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-8)
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
@ -232,23 +167,156 @@ final class PermissionTagChip: UIView {
fatalError("init(coder:) has not been implemented")
}
@objc private func removeTapped() {
onRemove?()
///
func setValue(_ text: String, isPlaceholder: Bool) {
valueLabel.text = text
valueLabel.textColor = isPlaceholder ? PermissionApplyUITokens.placeholder : PermissionApplyUITokens.text
selectorControl.accessibilityValue = text
}
@objc private func rowTapped() {
onTap?()
}
}
///
final class PermissionSubmitButton: UIButton {
///
enum PermissionChipStyle: Hashable {
case selectedRemovable
case selectedStatic
case existingGreen
}
override init(frame: CGRect) {
super.init(frame: frame)
setTitle("提交审核", for: .normal)
setTitleColor(.white, for: .normal)
titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
backgroundColor = AppColor.primary
layer.cornerRadius = 8
snp.makeConstraints { make in
make.height.equalTo(48)
/// Diffable
struct PermissionChipItem: Hashable {
let id: String
let title: String
}
/// Android `FlowRow`
struct PermissionChipLayoutMetrics {
static let itemHeight: CGFloat = 44
static let interitemSpacing: CGFloat = 12
static let lineSpacing: CGFloat = 2
///
static func makeFrames(itemWidths: [CGFloat], containerWidth: CGFloat) -> [CGRect] {
guard containerWidth > 0 else { return [] }
var x: CGFloat = 0
var y: CGFloat = 0
return itemWidths.map { rawWidth in
let width = min(containerWidth, max(44, rawWidth))
if x > 0, x + width > containerWidth + 0.5 {
x = 0
y += itemHeight + lineSpacing
}
let frame = CGRect(x: x, y: y, width: width, height: itemHeight)
x += width + interitemSpacing
return frame
}
}
}
/// Android `FlowRow`
private final class PermissionChipFlowLayout: UICollectionViewLayout {
var itemWidthProvider: ((IndexPath) -> CGFloat)?
private var cachedAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
private var contentHeight: CGFloat = 1
override func prepare() {
super.prepare()
guard let collectionView, collectionView.bounds.width > 0 else {
cachedAttributes = [:]
contentHeight = 1
return
}
cachedAttributes = [:]
let availableWidth = collectionView.bounds.width
var indexPaths: [IndexPath] = []
var itemWidths: [CGFloat] = []
for section in 0..<collectionView.numberOfSections {
for item in 0..<collectionView.numberOfItems(inSection: section) {
let indexPath = IndexPath(item: item, section: section)
indexPaths.append(indexPath)
itemWidths.append(itemWidthProvider?(indexPath) ?? 44)
}
}
let frames = PermissionChipLayoutMetrics.makeFrames(
itemWidths: itemWidths,
containerWidth: availableWidth
)
for (index, indexPath) in indexPaths.enumerated() {
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = frames[index]
cachedAttributes[indexPath] = attributes
}
contentHeight = frames.last?.maxY ?? 1
}
override var collectionViewContentSize: CGSize {
CGSize(width: collectionView?.bounds.width ?? 0, height: contentHeight)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
cachedAttributes.values.filter { $0.frame.intersects(rect) }
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
cachedAttributes[indexPath]
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard let collectionView else { return true }
return abs(collectionView.bounds.width - newBounds.width) > 0.5
}
}
///
final class PermissionChipCollectionView: UIView {
private enum Section { case main }
var onRemove: ((Int) -> Void)?
private let style: PermissionChipStyle
private let layout: PermissionChipFlowLayout
private let collectionView: UICollectionView
private var dataSource: UICollectionViewDiffableDataSource<Section, PermissionChipItem>!
private var items: [PermissionChipItem] = []
private var heightConstraint: Constraint?
init(style: PermissionChipStyle) {
self.style = style
let layout = PermissionChipFlowLayout()
self.layout = layout
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
super.init(frame: .zero)
collectionView.backgroundColor = .clear
collectionView.isScrollEnabled = false
collectionView.register(PermissionChipCell.self, forCellWithReuseIdentifier: PermissionChipCell.reuseIdentifier)
addSubview(collectionView)
collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() }
snp.makeConstraints { make in heightConstraint = make.height.equalTo(1).constraint }
dataSource = UICollectionViewDiffableDataSource<Section, PermissionChipItem>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, item in
guard let self,
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: PermissionChipCell.reuseIdentifier,
for: indexPath
) as? PermissionChipCell else { return UICollectionViewCell() }
cell.apply(title: item.title, style: self.style)
cell.onRemove = { [weak self] in
guard let self, let currentIndex = self.items.firstIndex(of: item) else { return }
self.onRemove?(currentIndex)
}
return cell
}
layout.itemWidthProvider = { [weak self] indexPath in
self?.itemWidth(at: indexPath) ?? 44
}
}
@ -257,9 +325,189 @@ final class PermissionSubmitButton: UIButton {
fatalError("init(coder:) has not been implemented")
}
func setSubmitting(_ submitting: Bool) {
isEnabled = !submitting
alpha = submitting ? 0.6 : 1
setTitle(submitting ? "提交中..." : "提交审核", for: .normal)
/// 使 Diffable snapshot
func apply(titles: [String]) {
var occurrences: [String: Int] = [:]
items = titles.map { title in
let occurrence = occurrences[title, default: 0]
occurrences[title] = occurrence + 1
return PermissionChipItem(id: "\(title)#\(occurrence)", title: title)
}
var snapshot = NSDiffableDataSourceSnapshot<Section, PermissionChipItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
self?.updateHeight()
}
}
override func layoutSubviews() {
super.layoutSubviews()
updateHeight()
}
private func updateHeight() {
collectionView.collectionViewLayout.invalidateLayout()
collectionView.layoutIfNeeded()
let height = max(1, collectionView.collectionViewLayout.collectionViewContentSize.height)
heightConstraint?.update(offset: height)
invalidateIntrinsicContentSize()
}
private func itemWidth(at indexPath: IndexPath) -> CGFloat {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return 44 }
let textWidth = ceil((item.title as NSString).size(withAttributes: [
.font: UIFont.systemFont(ofSize: 14),
]).width)
let horizontalChrome: CGFloat = style == .selectedRemovable ? 42 : 24
return textWidth + horizontalChrome
}
}
/// /
final class PermissionSelectedTagsView: UIView {
private let titleLabel = UILabel()
private let chips = PermissionChipCollectionView(style: .selectedRemovable)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = PermissionApplyUITokens.selectedBackground
layer.cornerRadius = 12
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = PermissionApplyUITokens.text
addSubview(titleLabel)
addSubview(chips)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(12)
}
chips.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(title: String, names: [String], onRemove: ((Int) -> Void)? = nil) {
titleLabel.text = title
isHidden = names.isEmpty
chips.onRemove = onRemove
chips.apply(titles: names)
}
}
///
final class PermissionSubmitButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setTitle("提交审核", for: .normal)
setTitleColor(.white, for: .normal)
titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
layer.cornerRadius = 8
accessibilityLabel = "提交审核"
snp.makeConstraints { make in make.height.equalTo(48) }
applyState(canSubmit: false, submitting: false)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func applyState(canSubmit: Bool, submitting: Bool) {
isEnabled = canSubmit && !submitting
backgroundColor = isEnabled ? PermissionApplyUITokens.selectedBlue : PermissionApplyUITokens.disabledButton
setTitle(submitting ? "提交中..." : "提交审核", for: .normal)
accessibilityValue = submitting ? "提交中" : nil
}
}
/// CollectionView
private final class PermissionChipCell: UICollectionViewCell {
static let reuseIdentifier = "PermissionChipCell"
var onRemove: (() -> Void)?
private let chipBackground = UIView()
private let label = UILabel()
private let removeButton = UIButton(type: .system)
private let removeImageView = UIImageView(image: UIImage(systemName: "xmark"))
override init(frame: CGRect) {
super.init(frame: frame)
label.font = .systemFont(ofSize: 14)
label.lineBreakMode = .byTruncatingTail
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
removeButton.accessibilityLabel = "删除"
removeImageView.contentMode = .scaleAspectFit
removeImageView.isUserInteractionEnabled = false
contentView.addSubview(chipBackground)
chipBackground.addSubview(label)
chipBackground.addSubview(removeImageView)
contentView.addSubview(removeButton)
chipBackground.snp.makeConstraints { make in
make.leading.trailing.centerY.equalToSuperview()
make.height.equalTo(34)
}
removeButton.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.size.equalTo(44)
}
removeImageView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-10)
make.centerY.equalToSuperview()
make.size.equalTo(14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String, style: PermissionChipStyle) {
label.text = title
chipBackground.layer.cornerRadius = style == .existingGreen ? 4 : 8
chipBackground.layer.borderWidth = 1
switch style {
case .selectedRemovable, .selectedStatic:
chipBackground.backgroundColor = style == .selectedRemovable
? PermissionApplyUITokens.selectedBackground : .white
chipBackground.layer.borderColor = PermissionApplyUITokens.selectedBlue.cgColor
label.textColor = PermissionApplyUITokens.selectedBlue
removeImageView.tintColor = PermissionApplyUITokens.selectedBlue
case .existingGreen:
chipBackground.backgroundColor = PermissionApplyUITokens.existingGreenBackground
chipBackground.layer.borderColor = PermissionApplyUITokens.existingGreen.cgColor
label.textColor = PermissionApplyUITokens.existingGreen
removeImageView.tintColor = PermissionApplyUITokens.existingGreen
}
let removable = style == .selectedRemovable
removeButton.isHidden = !removable
removeImageView.isHidden = !removable
label.snp.remakeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
if removable {
make.trailing.equalTo(removeImageView.snp.leading).offset(-6)
} else {
make.trailing.equalToSuperview().offset(-12)
}
}
accessibilityLabel = title
}
@objc private func removeTapped() {
onRemove?()
}
}