重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
397 lines
17 KiB
Swift
397 lines
17 KiB
Swift
//
|
||
// 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) }
|
||
}
|
||
}
|