完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
396
suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift
Normal file
396
suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift
Normal file
@ -0,0 +1,396 @@
|
||||
//
|
||||
// ScenicQueueOverlayViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// Android 排队模块样式的等宽标签栏。
|
||||
final class ScenicQueueTabStripView: UIView {
|
||||
private let stackView = UIStackView()
|
||||
private let indicator = UIView()
|
||||
private let divider = UIView()
|
||||
private var buttons: [UIButton] = []
|
||||
|
||||
/// 用户选择标签时回传索引。
|
||||
var onSelect: ((Int) -> Void)?
|
||||
|
||||
/// 当前选中索引。
|
||||
private(set) var selectedIndex = 0
|
||||
|
||||
init(titles: [String]) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .white
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fillEqually
|
||||
titles.enumerated().forEach { index, title in
|
||||
let button = UIButton(type: .system)
|
||||
button.tag = index
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = ScenicQueueTokens.tabFont
|
||||
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
|
||||
buttons.append(button)
|
||||
stackView.addArrangedSubview(button)
|
||||
}
|
||||
indicator.backgroundColor = ScenicQueueTokens.bannerBlue
|
||||
divider.backgroundColor = ScenicQueueTokens.cardOutline
|
||||
addSubview(stackView)
|
||||
addSubview(divider)
|
||||
addSubview(indicator)
|
||||
stackView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
divider.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(1 / UIScreen.main.scale)
|
||||
}
|
||||
setSelectedIndex(0, animated: false)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
updateIndicatorFrame()
|
||||
}
|
||||
|
||||
/// 更新选中标签并移动 3pt 蓝色指示条。
|
||||
func setSelectedIndex(_ index: Int, animated: Bool) {
|
||||
guard buttons.indices.contains(index) else { return }
|
||||
selectedIndex = index
|
||||
buttons.enumerated().forEach { item in
|
||||
item.element.setTitleColor(item.offset == index ? ScenicQueueTokens.bannerBlue : AppColor.textSecondary, for: .normal)
|
||||
item.element.titleLabel?.font = item.offset == index ? ScenicQueueTokens.tabSelectedFont : ScenicQueueTokens.tabFont
|
||||
}
|
||||
let changes = { self.updateIndicatorFrame() }
|
||||
animated ? UIView.animate(withDuration: 0.22, animations: changes) : changes()
|
||||
}
|
||||
|
||||
private func updateIndicatorFrame() {
|
||||
guard !buttons.isEmpty, bounds.width > 0 else { return }
|
||||
let width = bounds.width / CGFloat(buttons.count)
|
||||
indicator.frame = CGRect(x: width * CGFloat(selectedIndex), y: bounds.height - 3, width: width, height: 3)
|
||||
}
|
||||
|
||||
@objc private func buttonTapped(_ sender: UIButton) {
|
||||
setSelectedIndex(sender.tag, animated: true)
|
||||
onSelect?(sender.tag)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排队模块确认弹层的固定展示配置。
|
||||
struct ScenicQueueDialogConfiguration {
|
||||
let title: String
|
||||
let message: String?
|
||||
let emphasizedText: String?
|
||||
let dangerText: String?
|
||||
let cancelTitle: String
|
||||
let confirmTitle: String
|
||||
let buttonRadius: CGFloat
|
||||
let dismissOnBackdrop: Bool
|
||||
}
|
||||
|
||||
/// Android 排队模块样式的居中确认弹层。
|
||||
final class ScenicQueueConfirmationDialogViewController: UIViewController, UIGestureRecognizerDelegate {
|
||||
private let configuration: ScenicQueueDialogConfiguration
|
||||
private let onCancel: () -> Void
|
||||
private let onConfirm: () -> Void
|
||||
|
||||
init(configuration: ScenicQueueDialogConfiguration, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
|
||||
self.configuration = configuration
|
||||
self.onCancel = onCancel
|
||||
self.onConfirm = onConfirm
|
||||
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 = UIColor.black.withAlphaComponent(0.42)
|
||||
if configuration.dismissOnBackdrop {
|
||||
let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
|
||||
backdropTap.delegate = self
|
||||
view.addGestureRecognizer(backdropTap)
|
||||
}
|
||||
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = ScenicQueueTokens.radiusDialog
|
||||
card.layer.shadowColor = UIColor.black.cgColor
|
||||
card.layer.shadowOpacity = 0.18
|
||||
card.layer.shadowRadius = 8
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = configuration.title
|
||||
titleLabel.font = ScenicQueueTokens.dialogTitleFont
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let contentStack = UIStackView(arrangedSubviews: [titleLabel])
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 10
|
||||
if let message = configuration.message {
|
||||
let label = makeLabel(message, font: .systemFont(ofSize: 15), color: AppColor.textSecondary)
|
||||
contentStack.addArrangedSubview(label)
|
||||
}
|
||||
if let danger = configuration.dangerText {
|
||||
let label = makeLabel(danger, font: .systemFont(ofSize: 13), color: ScenicQueueTokens.dialogDangerLine)
|
||||
contentStack.addArrangedSubview(label)
|
||||
}
|
||||
if let emphasized = configuration.emphasizedText {
|
||||
let label = makeLabel(emphasized, font: ScenicQueueTokens.dialogTicketFont, color: AppColor.textPrimary)
|
||||
contentStack.addArrangedSubview(label)
|
||||
contentStack.setCustomSpacing(20, after: label)
|
||||
}
|
||||
|
||||
let cancel = makeButton(configuration.cancelTitle, background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
|
||||
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
let confirm = makeButton(configuration.confirmTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
||||
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
cancel.layer.cornerRadius = configuration.buttonRadius
|
||||
confirm.layer.cornerRadius = configuration.buttonRadius
|
||||
let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
|
||||
buttons.axis = .horizontal
|
||||
buttons.distribution = .fillEqually
|
||||
buttons.spacing = 12
|
||||
contentStack.addArrangedSubview(buttons)
|
||||
|
||||
view.addSubview(card)
|
||||
card.addSubview(contentStack)
|
||||
card.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
|
||||
buttons.snp.makeConstraints { $0.height.equalTo(44) }
|
||||
}
|
||||
|
||||
private func makeLabel(_ text: String, font: UIFont, color: UIColor) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = font
|
||||
label.textColor = color
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(foreground, for: .normal)
|
||||
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
|
||||
button.backgroundColor = background
|
||||
button.clipsToBounds = true
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
dismiss(animated: true, completion: onCancel)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
dismiss(animated: true, completion: onConfirm)
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
touch.view === view
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 排队模块样式的拨号底部弹层。
|
||||
final class ScenicQueueDialSheetViewController: UIViewController, UIGestureRecognizerDelegate {
|
||||
private let digits: String
|
||||
|
||||
init(digits: String) {
|
||||
self.digits = digits
|
||||
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 = UIColor.black.withAlphaComponent(0.36)
|
||||
let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
|
||||
backdropTap.delegate = self
|
||||
view.addGestureRecognizer(backdropTap)
|
||||
let panel = UIView()
|
||||
panel.backgroundColor = .white
|
||||
panel.layer.cornerRadius = 18
|
||||
panel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
let title = UILabel()
|
||||
title.text = "拨打电话"
|
||||
title.font = ScenicQueueTokens.dialogTitleFont
|
||||
title.textAlignment = .center
|
||||
let number = UILabel()
|
||||
number.text = digits
|
||||
number.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
number.textColor = AppColor.textPrimary
|
||||
number.textAlignment = .center
|
||||
let cancel = pillButton("取消", background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
|
||||
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
let dial = pillButton("拨打", background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
||||
dial.addTarget(self, action: #selector(dialTapped), for: .touchUpInside)
|
||||
let buttons = UIStackView(arrangedSubviews: [cancel, dial])
|
||||
buttons.axis = .horizontal
|
||||
buttons.distribution = .fillEqually
|
||||
buttons.spacing = 12
|
||||
view.addSubview(panel)
|
||||
[title, number, buttons].forEach(panel.addSubview)
|
||||
panel.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() }
|
||||
title.snp.makeConstraints { make in make.top.equalToSuperview().offset(18); make.centerX.equalToSuperview() }
|
||||
number.snp.makeConstraints { make in make.top.equalTo(title.snp.bottom).offset(18); make.leading.trailing.equalToSuperview().inset(16) }
|
||||
buttons.snp.makeConstraints { make in
|
||||
make.top.equalTo(number.snp.bottom).offset(18)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(44)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
}
|
||||
|
||||
private func pillButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(foreground, for: .normal)
|
||||
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
|
||||
button.backgroundColor = background
|
||||
button.layer.cornerRadius = 22
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() { dismiss(animated: true) }
|
||||
|
||||
@objc private func dialTapped() {
|
||||
dismiss(animated: true) {
|
||||
guard let url = URL(string: "tel:\(self.digits)") else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
touch.view === view
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 用户标记弹层,支持打野标记和限制排队天数步进。
|
||||
final class ScenicQueueMarkDialogViewController: UIViewController {
|
||||
private let pending: ScenicQueuePendingMark
|
||||
private let onCancel: () -> Void
|
||||
private let onConfirm: (Bool, Int) -> Void
|
||||
private let markSwitch = UISwitch()
|
||||
private let daysLabel = UILabel()
|
||||
private var days = 0 { didSet { daysLabel.text = "\(days)" } }
|
||||
|
||||
init(pending: ScenicQueuePendingMark, onCancel: @escaping () -> Void, onConfirm: @escaping (Bool, Int) -> Void) {
|
||||
self.pending = pending
|
||||
self.onCancel = onCancel
|
||||
self.onConfirm = onConfirm
|
||||
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 = UIColor.black.withAlphaComponent(0.42)
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = ScenicQueueTokens.markDialogRadius
|
||||
let title = UILabel()
|
||||
title.text = "标记用户"
|
||||
title.font = ScenicQueueTokens.dialogTitleFont
|
||||
title.textAlignment = .center
|
||||
let queue = UILabel()
|
||||
queue.text = pending.queueNo
|
||||
queue.font = .systemFont(ofSize: 24, weight: .bold)
|
||||
queue.textColor = ScenicQueueTokens.bannerBlue
|
||||
let phone = UILabel()
|
||||
phone.text = pending.phoneMasked
|
||||
phone.font = ScenicQueueTokens.phoneMaskedFont
|
||||
phone.textColor = AppColor.textSecondary
|
||||
let identityTitle = UILabel()
|
||||
identityTitle.text = "标记为打野摄影师"
|
||||
identityTitle.font = ScenicQueueTokens.settingsRowTitleFont
|
||||
markSwitch.isOn = pending.markAsFreelancePhotog == 1
|
||||
markSwitch.onTintColor = ScenicQueueTokens.bannerBlue
|
||||
let identityRow = UIStackView(arrangedSubviews: [identityTitle, UIView(), markSwitch])
|
||||
identityRow.axis = .horizontal
|
||||
identityRow.alignment = .center
|
||||
let hint = UILabel()
|
||||
hint.text = "标记后,该用户扫码时将受排队限制。"
|
||||
hint.font = ScenicQueueTokens.settingsHintFont
|
||||
hint.textColor = AppColor.textTertiary
|
||||
hint.numberOfLines = 0
|
||||
let daysTitle = UILabel()
|
||||
daysTitle.text = "限制排队天数"
|
||||
daysTitle.font = ScenicQueueTokens.settingsRowTitleFont
|
||||
let minus = stepButton("−", action: #selector(minusTapped))
|
||||
let plus = stepButton("+", action: #selector(plusTapped))
|
||||
daysLabel.text = "0"
|
||||
daysLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
daysLabel.textAlignment = .center
|
||||
let stepper = UIStackView(arrangedSubviews: [minus, daysLabel, plus])
|
||||
stepper.axis = .horizontal
|
||||
stepper.alignment = .center
|
||||
stepper.distribution = .fillEqually
|
||||
stepper.backgroundColor = ScenicQueueTokens.markDialogStepperBackground
|
||||
stepper.layer.cornerRadius = 10
|
||||
let cancel = actionButton("取消", background: ScenicQueueTokens.markDialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
|
||||
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
let confirm = actionButton("完成", background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
||||
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
|
||||
buttons.axis = .horizontal
|
||||
buttons.distribution = .fillEqually
|
||||
buttons.spacing = 12
|
||||
let stack = UIStackView(arrangedSubviews: [title, queue, phone, identityRow, hint, daysTitle, stepper, buttons])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
view.addSubview(card)
|
||||
card.addSubview(stack)
|
||||
card.snp.makeConstraints { make in make.center.equalToSuperview(); make.width.equalToSuperview().multipliedBy(0.88) }
|
||||
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
|
||||
stepper.snp.makeConstraints { $0.height.equalTo(44) }
|
||||
buttons.snp.makeConstraints { $0.height.equalTo(44) }
|
||||
}
|
||||
|
||||
private func stepButton(_ title: String, action: Selector) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 22, weight: .medium)
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
private func actionButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(foreground, for: .normal)
|
||||
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
|
||||
button.backgroundColor = background
|
||||
button.layer.cornerRadius = ScenicQueueTokens.markDialogButtonRadius
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func minusTapped() { days = max(0, days - 1) }
|
||||
@objc private func plusTapped() { days = min(999, days + 1) }
|
||||
@objc private func cancelTapped() { dismiss(animated: true, completion: onCancel) }
|
||||
@objc private func confirmTapped() {
|
||||
let marked = markSwitch.isOn
|
||||
dismiss(animated: true) { self.onConfirm(marked, marked ? self.days : 0) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user