Files
suixinkan_uikit/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift
汉秋 5eef31b8da 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:51:23 +08:00

931 lines
42 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ScenicQueueSettingsViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `ScenicQueueSettingsScreen`
final class ScenicQueueSettingsViewController: BaseViewController {
private let viewModel: ScenicQueueSettingsViewModel
private let api: ScenicQueueAPIProtocol
private let tabStrip = ScenicQueueTabStripView(
titles: ScenicQueueSettingsTab.allCases.map(\.title))
private let pageController = UIPageViewController(
transitionStyle: .scroll, navigationOrientation: .horizontal)
private let saveBar = UIView()
private let saveButton = UIButton(type: .system)
private var pages: [ScenicQueueSettingsPageController] = []
private var numericFields: [String: ScenicQueueNumericFieldView] = [:]
private var switches: [String: ScenicQueueSwitchRow] = [:]
private let punchSpotButton = UIButton(type: .system)
private var selectedPunchLabel = UILabel()
private var punchSpotOptionsLoading = false
private var punchSpotOptionsLoaded = false
private var startTimeButton = UIButton(type: .system)
private var endTimeButton = UIButton(type: .system)
private var customTextView = UITextView()
private var customPlayButton = UIButton(type: .system)
private var presetStack = UIStackView()
private var presetSummaryLabel = UILabel()
@MainActor
init(
api: ScenicQueueAPIProtocol? = nil,
viewModel: ScenicQueueSettingsViewModel? = nil
) {
self.api = api ?? NetworkServices.shared.scenicQueueAPI
self.viewModel = viewModel ?? ScenicQueueSettingsViewModel()
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = "设置"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "配置日志", style: .plain, target: self, action: #selector(logTapped))
}
override func setupUI() {
view.backgroundColor = ScenicQueueTokens.pageBackground
pageController.dataSource = self
pageController.delegate = self
addChild(pageController)
view.addSubview(tabStrip)
view.addSubview(pageController.view)
pageController.didMove(toParent: self)
pages = ScenicQueueSettingsTab.allCases.map { ScenicQueueSettingsPageController(tab: $0) }
buildBasicPage(pages[0])
buildNotifyPage(pages[1])
buildVoicePage(pages[2])
pageController.setViewControllers([pages[0]], direction: .forward, animated: false)
saveBar.backgroundColor = .white
saveBar.layer.shadowColor = UIColor.black.cgColor
saveBar.layer.shadowOpacity = 0.08
saveBar.layer.shadowRadius = 6
saveBar.layer.shadowOffset = CGSize(width: 0, height: -2)
saveButton.setTitle("保存", for: .normal)
saveButton.setTitleColor(.white, for: .normal)
saveButton.titleLabel?.font = ScenicQueueTokens.settingsSaveButtonFont
saveButton.backgroundColor = ScenicQueueTokens.bannerBlue
saveButton.layer.cornerRadius = ScenicQueueTokens.bottomSaveButtonRadius
saveBar.addSubview(saveButton)
view.addSubview(saveBar)
applyState()
}
override func setupConstraints() {
tabStrip.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalTo(48)
}
saveBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() }
saveButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(31)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
pageController.view.snp.makeConstraints { make in
make.top.equalTo(tabStrip.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(saveBar.snp.top)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadPunchSpotMenuIfNeeded()
}
override func bindActions() {
tabStrip.onSelect = { [weak self] index in self?.selectPage(index, animated: true) }
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onNavigateBack = { [weak self] in
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
}
viewModel.onNavigateLog = { [weak self] in
Task { @MainActor in
self?.navigationController?.pushViewController(
ScenicQueueSettingChangeLogViewController(), animated: true)
}
}
}
private func selectPage(_ index: Int, animated: Bool) {
guard pages.indices.contains(index) else { return }
let current = viewModel.settingsTab.rawValue
let direction: UIPageViewController.NavigationDirection = index >= current ? .forward : .reverse
viewModel.setSettingsTab(ScenicQueueSettingsTab(rawValue: index) ?? .basic)
pageController.setViewControllers([pages[index]], direction: direction, animated: animated)
tabStrip.setSelectedIndex(index, animated: animated)
}
private func applyState() {
let selected = viewModel.settingsTab.rawValue
tabStrip.setSelectedIndex(selected, animated: true)
if pageController.viewControllers?.first !== pages[selected] {
pageController.setViewControllers([pages[selected]], direction: .forward, animated: false)
}
selectedPunchLabel.text = viewModel.selectedPunchSpotDisplayText
configurePunchSpotMenu()
startTimeButton.setTitle(viewModel.businessStartTime, for: .normal)
endTimeButton.setTitle(viewModel.businessEndTime, for: .normal)
updateField(
"queueDistanceMeter",
ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: viewModel.queueDistanceMeter))
updateField("shootMinute", "\(viewModel.shootMinute)")
updateField("shootSecond", "\(viewModel.shootSecond)")
updateField("queueTakeLimit", "\(viewModel.queueTakeLimit)")
updateField("missCallRequeueOffset", "\(viewModel.missCallRequeueOffset)")
updateField("autoCallAheadCount", "\(viewModel.autoCallAheadCount)")
updateField("firstNoticeAhead", "\(viewModel.firstAheadCount)")
updateField("secondNoticeAhead", "\(viewModel.secondAheadCount)")
updateField("broadcastIntervalSec", "\(viewModel.broadcastIntervalSec)")
updateField("countdownReadableThresholdSec", "\(viewModel.countdownThresholdSec)")
setSwitch("businessOpen", viewModel.businessOpen)
setSwitch("showShoot", viewModel.showStartShootingButton)
setSwitch("quickCall", viewModel.quickCallButtonEnabled)
setSwitch("prepareCall", viewModel.prepareCallButtonEnabled)
setSwitch("firstSms", viewModel.firstSms)
setSwitch("firstPhone", viewModel.firstPhone)
setSwitch("secondSms", viewModel.secondSms)
setSwitch("secondPhone", viewModel.secondPhone)
if !customTextView.isFirstResponder { customTextView.text = viewModel.customTTSText }
customTextView.isEditable = !viewModel.isCustomTTSLooping
customPlayButton.setTitle(viewModel.isCustomTTSLooping ? "停止播放" : "试听", for: .normal)
rebuildPresetRows()
viewModel.highlightedInvalidFieldKeys.forEach { numericFields[$0]?.setInvalid(true) }
}
private func updateField(_ key: String, _ value: String) {
let field = numericFields[key]
field?.setInvalid(viewModel.highlightedInvalidFieldKeys.contains(key))
field?.setDisplayValue(value)
}
private func setSwitch(_ key: String, _ value: Bool) { switches[key]?.setValue(value) }
private func buildBasicPage(_ page: ScenicQueueSettingsPageController) {
page.stack.addArrangedSubview(tipCard())
page.stack.addArrangedSubview(punchSpotCard())
page.stack.addArrangedSubview(businessCard())
page.stack.addArrangedSubview(
sectionCard(title: "排队距离限制") {
let hint = self.hintLabel("顾客仅可在该距离范围内取号0 表示不限制。合法范围 09999.99 千米。")
let field = self.numericField(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad)
return [hint, field]
})
page.stack.addArrangedSubview(
sectionCard(title: "预计拍摄时长") {
let minute = self.numericField(key: "shootMinute", unit: "")
let second = self.numericField(key: "shootSecond", unit: "")
let row = UIStackView(arrangedSubviews: [minute, second])
row.axis = .horizontal
row.spacing = 12
row.distribution = .fillEqually
return [self.hintLabel("总时长必须大于 0分钟 030秒 059。"), row]
})
page.stack.addArrangedSubview(
sectionCard(title: "单人取号次数限制") {
[
self.hintLabel("0 表示不限制,最大 999999 次。"),
self.numericField(key: "queueTakeLimit", unit: ""),
]
})
page.stack.addArrangedSubview(
sectionCard(title: "过号重新排队") {
[
self.hintLabel("设置过号重新排队时相对队首顺延的位数,范围 19999。"),
self.numericField(key: "missCallRequeueOffset", unit: ""),
]
})
page.stack.addArrangedSubview(
sectionCard(title: "拍摄与叫号配置", info: true) {
let shoot = self.switchRow(
key: "showShoot", title: "显示开始拍摄按钮", action: #selector(self.showShootChanged(_:)))
let auto = self.numericField(key: "autoCallAheadCount", unit: "")
return [shoot, self.divider(), self.hintLabel("开始拍摄后自动通知后续 05 个排队号。"), auto]
})
page.stack.addArrangedSubview(
sectionCard(title: "底部叫号按钮") {
[
self.switchRow(
key: "quickCall", title: "显示快速叫号", action: #selector(self.quickCallChanged(_:))),
self.divider(),
self.switchRow(
key: "prepareCall", title: "显示准备叫号", action: #selector(self.prepareCallChanged(_:))),
]
})
}
private func buildNotifyPage(_ page: ScenicQueueSettingsPageController) {
page.stack.addArrangedSubview(
sectionCard(title: "提前通知规则", info: true) {
[
self.sectionLabel("第一次提前通知"),
self.hintLabel("前方人数达到阈值时触发,范围 050 位。"),
self.numericField(key: "firstNoticeAhead", unit: ""),
self.switchRow(
key: "firstSms", title: "短信通知", action: #selector(self.firstSmsChanged(_:))),
self.switchRow(
key: "firstPhone", title: "电话通知(自动语音)", action: #selector(self.firstPhoneChanged(_:))),
self.divider(), self.sectionLabel("第二次提前通知"),
self.hintLabel("可设置更接近队首的二次提醒,范围 050 位。"),
self.numericField(key: "secondNoticeAhead", unit: ""),
self.switchRow(
key: "secondSms", title: "短信通知", action: #selector(self.secondSmsChanged(_:))),
self.switchRow(
key: "secondPhone", title: "电话通知(自动语音)", action: #selector(self.secondPhoneChanged(_:))),
]
})
}
private func buildVoicePage(_ page: ScenicQueueSettingsPageController) {
page.stack.addArrangedSubview(
sectionCard(title: "倒计时播报配置", info: true) {
let test = self.primaryButton("播放测试声音", action: #selector(self.testSoundTapped))
return [
self.sectionLabel("播报间隔时间"), self.hintLabel("从开拍起按间隔播报剩余时间,合法范围 4060 秒。"),
self.numericField(key: "broadcastIntervalSec", unit: ""),
self.sectionLabel("进入读秒倒计时阈值"), self.hintLabel("到达阈值后逐秒播报,合法范围 1030 秒。"),
self.numericField(key: "countdownReadableThresholdSec", unit: ""),
self.sectionLabel("测试蓝牙音响"), test,
]
})
page.stack.addArrangedSubview(customTTSCard())
page.stack.addArrangedSubview(presetVoicesCard())
}
private func tipCard() -> UIView {
let card = ScenicQueueSettingCard()
card.backgroundColor = ScenicQueueTokens.bannerBlue
card.layer.borderWidth = 0
let title = sectionLabel("温馨提示")
title.textColor = .white
let body = hintLabel("配置信息修改后,请及时测试叫号设备状态")
body.textColor = UIColor.white.withAlphaComponent(0.92)
let stack = verticalStack([title, body], spacing: 6)
card.addSubview(stack)
stack.snp.makeConstraints {
$0.edges.equalToSuperview().inset(UIEdgeInsets(top: 20, left: 16, bottom: 20, right: 16))
}
return card
}
private func punchSpotCard() -> UIView {
sectionCard(title: "打卡点信息") {
let label = self.hintLabel("选择打卡点")
let selector = self.punchSpotButton
selector.backgroundColor = .white
selector.layer.cornerRadius = ScenicQueueTokens.radiusInput
selector.layer.borderWidth = 1
selector.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor
selector.showsMenuAsPrimaryAction = true
self.selectedPunchLabel.font = ScenicQueueTokens.settingsRowTitleFont
self.selectedPunchLabel.textColor = AppColor.textPrimary
let arrow = UILabel()
arrow.text = ""
arrow.font = ScenicQueueTokens.settingsHintFont
arrow.textColor = AppColor.textTertiary
selector.addSubview(self.selectedPunchLabel)
selector.addSubview(arrow)
self.selectedPunchLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(arrow.snp.leading).offset(-8)
}
arrow.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
}
selector.snp.makeConstraints { $0.height.equalTo(52) }
let qrLabel = self.sectionLabel("点击保存打卡点排队二维码")
let qr = self.compactPrimaryButton("保存二维码", action: #selector(self.saveQRTapped))
let qrRow = UIStackView(arrangedSubviews: [qrLabel, qr])
qrRow.axis = .horizontal
qrRow.alignment = .center
qrRow.spacing = 10
return [label, selector, qrRow]
}
}
private func businessCard() -> UIView {
sectionCard(title: "排队状态与时间", info: true) {
let status = self.switchRow(
key: "businessOpen", title: "当前排队状态", action: #selector(self.businessOpenChanged(_:)))
self.startTimeButton = self.timeButton(action: #selector(self.startTimeTapped))
self.endTimeButton = self.timeButton(action: #selector(self.endTimeTapped))
let times = UIStackView(arrangedSubviews: [
self.labeledControl("营业开始时间", self.startTimeButton),
self.labeledControl("营业结束时间", self.endTimeButton),
])
times.axis = .horizontal
times.distribution = .fillEqually
times.spacing = 12
return [status, self.hintLabel("开启后顾客可在营业时间内扫码取号;关闭后暂停排队。"), self.divider(), times]
}
}
private func customTTSCard() -> UIView {
sectionCard(title: "自定义文本语音播报") {
self.customTextView.font = ScenicQueueTokens.settingsRowTitleFont
self.customTextView.textColor = AppColor.textPrimary
self.customTextView.layer.cornerRadius = ScenicQueueTokens.radiusInput
self.customTextView.layer.borderWidth = 1
self.customTextView.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor
self.customTextView.delegate = self
self.customTextView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
self.customTextView.snp.makeConstraints { $0.height.equalTo(104) }
self.customPlayButton = self.outlinedButton("试听", action: #selector(self.customPlayTapped))
let save = self.primaryButton("保存", action: #selector(self.savePresetTapped))
let row = UIStackView(arrangedSubviews: [self.customPlayButton, save])
row.axis = .horizontal
row.distribution = .fillEqually
row.spacing = 12
return [self.customTextView, row]
}
}
private func presetVoicesCard() -> UIView {
let card = ScenicQueueSettingCard()
presetSummaryLabel.font = ScenicQueueTokens.settingsSectionTitleFont
presetSummaryLabel.textColor = AppColor.textPrimary
presetStack.axis = .vertical
presetStack.spacing = 10
let hint = hintLabel("左滑显示删除;点左侧图标循环播报该条。底部「保存」提交全部排队参数。")
let stack = verticalStack([presetSummaryLabel, presetStack, hint], spacing: 14)
card.addSubview(stack)
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
return card
}
private func rebuildPresetRows() {
presetSummaryLabel.text = "已保存的语音(\(viewModel.presetVoices.count)/5)"
presetStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
guard !viewModel.presetVoices.isEmpty else {
presetStack.addArrangedSubview(hintLabel("暂无预设文案,输入后点击「保存」添加到本地列表(最多 5 条)。"))
return
}
for (index, phrase) in viewModel.presetVoices.enumerated() {
let playing =
viewModel.isCustomTTSLooping
&& viewModel.ttsLoopAnchorText?.trimmingCharacters(in: .whitespacesAndNewlines)
== phrase.trimmingCharacters(in: .whitespacesAndNewlines)
let row = ScenicQueuePresetVoiceRow(text: phrase, playing: playing)
row.tag = index
row.onPlay = { [weak self] in
guard let self else { return }
if playing {
self.viewModel.toggleCustomTTSPlayback()
} else {
self.viewModel.startPresetVoiceLoop(phrase)
}
}
row.onDelete = { [weak self] in self?.viewModel.deletePresetVoice(at: index) }
presetStack.addArrangedSubview(row)
}
}
private func sectionCard(title: String, info: Bool = false, content: () -> [UIView]) -> UIView {
let card = ScenicQueueSettingCard()
let heading = sectionLabel(title)
if info { heading.text = "\(title)" }
let stack = verticalStack([heading] + content(), spacing: 12)
card.addSubview(stack)
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
return card
}
private func numericField(key: String, unit: String, keyboard: UIKeyboardType = .numberPad)
-> ScenicQueueNumericFieldView
{
let field = ScenicQueueNumericFieldView(key: key, unit: unit, keyboard: keyboard)
field.delegate = self
numericFields[key] = field
return field
}
private func switchRow(key: String, title: String, action: Selector) -> ScenicQueueSwitchRow {
let row = ScenicQueueSwitchRow(title: title)
row.toggle.addTarget(self, action: action, for: .valueChanged)
switches[key] = row
return row
}
private func verticalStack(_ views: [UIView], spacing: CGFloat) -> UIStackView {
let stack = UIStackView(arrangedSubviews: views)
stack.axis = .vertical
stack.spacing = spacing
return stack
}
private func sectionLabel(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = ScenicQueueTokens.settingsSectionTitleFont
label.textColor = AppColor.textPrimary
label.numberOfLines = 0
return label
}
private func hintLabel(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = ScenicQueueTokens.settingsHintFont
label.textColor = AppColor.textTertiary
label.numberOfLines = 0
return label
}
private func divider() -> UIView {
let view = UIView()
view.backgroundColor = ScenicQueueTokens.cardOutline
view.snp.makeConstraints { $0.height.equalTo(1 / UIScreen.main.scale) }
return view
}
private func primaryButton(_ title: String, action: Selector) -> UIButton {
let button = compactPrimaryButton(title, action: action)
button.snp.makeConstraints { $0.height.equalTo(44) }
return button
}
private func compactPrimaryButton(_ title: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.settingsRowTitleFont
button.backgroundColor = ScenicQueueTokens.bannerBlue
button.layer.cornerRadius = ScenicQueueTokens.radiusInput
button.addTarget(self, action: action, for: .touchUpInside)
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
)
return button
}
private func outlinedButton(_ title: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.settingsRowTitleFont
button.layer.cornerRadius = ScenicQueueTokens.radiusInput
button.layer.borderWidth = 1
button.layer.borderColor = ScenicQueueTokens.bannerBlue.cgColor
button.addTarget(self, action: action, for: .touchUpInside)
button.snp.makeConstraints { $0.height.equalTo(44) }
return button
}
private func timeButton(action: Selector) -> UIButton {
let button = outlinedButton("--:--", action: action)
button.setTitleColor(AppColor.textPrimary, for: .normal)
button.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor
return button
}
private func labeledControl(_ title: String, _ control: UIView) -> UIView {
let label = hintLabel(title)
let stack = verticalStack([label, control], spacing: 8)
return stack
}
private func loadPunchSpotMenuIfNeeded() {
guard !punchSpotOptionsLoaded, !punchSpotOptionsLoading else { return }
punchSpotOptionsLoading = true
configurePunchSpotMenu()
Task {
await viewModel.loadPunchSpotOptions(api: api)
punchSpotOptionsLoading = false
punchSpotOptionsLoaded = true
configurePunchSpotMenu()
}
}
private func configurePunchSpotMenu() {
if punchSpotOptionsLoading {
punchSpotButton.menu = UIMenu(children: [
UIAction(title: "正在加载打卡点…", attributes: .disabled) { _ in }
])
return
}
let actions = viewModel.punchSpotOptions.map { option in
UIAction(
title: option.label,
state: option.id == viewModel.selectedPunchSpotId ? .on : .off
) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.setPunchSpot(option, api: self.api) }
}
}
punchSpotButton.menu = UIMenu(
options: .singleSelection,
children: actions.isEmpty
? [UIAction(title: "暂无可选打卡点", attributes: .disabled) { _ in }]
: actions
)
}
private func presentTimePicker(initial: String, setter: @escaping (String) -> Void) {
present(
ScenicQueueTimePickerViewController(initialTime: initial, onConfirm: setter), animated: true)
}
@objc private func logTapped() { viewModel.openSettingChangeLog() }
@objc private func saveTapped() {
view.endEditing(true)
Task { await viewModel.save(api: api) }
}
@objc private func saveQRTapped() { Task { await viewModel.saveShootQueueQRCode(api: api) } }
@objc private func startTimeTapped() {
presentTimePicker(initial: viewModel.businessStartTime, setter: viewModel.setBusinessStartTime)
}
@objc private func endTimeTapped() {
presentTimePicker(initial: viewModel.businessEndTime, setter: viewModel.setBusinessEndTime)
}
@objc private func testSoundTapped() { viewModel.playBluetoothTestSound() }
@objc private func customPlayTapped() { viewModel.toggleCustomTTSPlayback() }
@objc private func savePresetTapped() {
viewModel.setCustomTTSText(customTextView.text ?? "")
viewModel.saveCurrentTextAsPresetVoiceLocal()
}
@objc private func businessOpenChanged(_ sender: UISwitch) { viewModel.toggleBusinessOpen() }
@objc private func showShootChanged(_ sender: UISwitch) {
viewModel.toggleShowStartShootingButton()
}
@objc private func quickCallChanged(_ sender: UISwitch) {
viewModel.toggleQuickCallButtonEnabled()
}
@objc private func prepareCallChanged(_ sender: UISwitch) {
viewModel.togglePrepareCallButtonEnabled()
}
@objc private func firstSmsChanged(_ sender: UISwitch) { viewModel.toggleFirstSms() }
@objc private func firstPhoneChanged(_ sender: UISwitch) { viewModel.toggleFirstPhone() }
@objc private func secondSmsChanged(_ sender: UISwitch) { viewModel.toggleSecondSms() }
@objc private func secondPhoneChanged(_ sender: UISwitch) { viewModel.toggleSecondPhone() }
}
extension ScenicQueueSettingsViewController: UIPageViewControllerDataSource,
UIPageViewControllerDelegate
{
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController
) -> UIViewController? {
guard let page = viewController as? ScenicQueueSettingsPageController,
page.settingsTab.rawValue > 0
else { return nil }
return pages[page.settingsTab.rawValue - 1]
}
func pageViewController(
_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController
) -> UIViewController? {
guard let page = viewController as? ScenicQueueSettingsPageController,
page.settingsTab.rawValue < pages.count - 1
else { return nil }
return pages[page.settingsTab.rawValue + 1]
}
func pageViewController(
_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool,
previousViewControllers: [UIViewController], transitionCompleted completed: Bool
) {
guard completed,
let page = pageViewController.viewControllers?.first as? ScenicQueueSettingsPageController
else { return }
viewModel.setSettingsTab(page.settingsTab)
tabStrip.setSelectedIndex(page.settingsTab.rawValue, animated: true)
}
}
extension ScenicQueueSettingsViewController: ScenicQueueNumericFieldViewDelegate {
func numericFieldView(_ field: ScenicQueueNumericFieldView, commit raw: String, key: String)
-> Bool
{
func integer(_ range: ClosedRange<Int>, setter: (Int) -> Void) -> Bool {
guard let value = Int(raw), range.contains(value) else { return false }
setter(value)
return true
}
let valid: Bool
switch key {
case "queueDistanceMeter":
if let meters = ScenicQueueSettingsViewModel.meters(fromKilometersText: raw) {
viewModel.setQueueDistanceMeter(meters)
valid = true
} else {
valid = false
}
case "shootMinute": valid = integer(0...30, setter: viewModel.setShootMinute)
case "shootSecond": valid = integer(0...59, setter: viewModel.setShootSecond)
case "queueTakeLimit": valid = integer(0...999_999, setter: viewModel.setQueueTakeLimit)
case "missCallRequeueOffset":
valid = integer(1...9_999, setter: viewModel.setMissCallRequeueOffset)
case "autoCallAheadCount": valid = integer(0...5, setter: viewModel.setAutoCallAheadCount)
case "firstNoticeAhead": valid = integer(0...50, setter: viewModel.setFirstAheadCount)
case "secondNoticeAhead": valid = integer(0...50, setter: viewModel.setSecondAheadCount)
case "broadcastIntervalSec": valid = integer(40...60, setter: viewModel.setBroadcastIntervalSec)
case "countdownReadableThresholdSec":
valid = integer(10...30, setter: viewModel.setCountdownThresholdSec)
default: valid = false
}
viewModel.reportOutOfRangeNumericPending(fieldKey: key, pending: !valid)
if !valid { showToast("输入值超出允许范围") }
return valid
}
}
extension ScenicQueueSettingsViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 255 { textView.text = String(textView.text.prefix(255)) }
viewModel.setCustomTTSText(textView.text)
}
}
///
final class ScenicQueueSettingsPageController: UIViewController {
let settingsTab: ScenicQueueSettingsTab
let stack = UIStackView()
private let scrollView = UIScrollView()
init(tab: ScenicQueueSettingsTab) {
self.settingsTab = tab
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = ScenicQueueTokens.pageBackground
scrollView.keyboardDismissMode = .onDrag
scrollView.alwaysBounceVertical = true
stack.axis = .vertical
stack.spacing = 14
view.addSubview(scrollView)
scrollView.addSubview(stack)
scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 16, right: 16))
make.width.equalToSuperview().offset(-32)
}
}
}
///
final class ScenicQueueSettingCard: UIView {
init() {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = ScenicQueueTokens.radiusCard
layer.borderWidth = 1
layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
}
@available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
protocol ScenicQueueNumericFieldViewDelegate: AnyObject {
func numericFieldView(_ field: ScenicQueueNumericFieldView, commit raw: String, key: String)
-> Bool
}
/// Android OutlinedTextField
final class ScenicQueueNumericFieldView: UIView, UITextFieldDelegate {
let key: String
weak var delegate: ScenicQueueNumericFieldViewDelegate?
private let textField = UITextField()
private var invalid = false
init(key: String, unit: String, keyboard: UIKeyboardType) {
self.key = key
super.init(frame: .zero)
let unitLabel = UILabel()
unitLabel.text = unit
unitLabel.font = ScenicQueueTokens.settingsHintFont
unitLabel.textColor = AppColor.textSecondary
unitLabel.textAlignment = .right
unitLabel.setContentHuggingPriority(.required, for: .horizontal)
unitLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
textField.keyboardType = keyboard
textField.font = ScenicQueueTokens.settingsRowTitleFont
textField.textColor = AppColor.textPrimary
textField.delegate = self
layer.cornerRadius = ScenicQueueTokens.radiusInput
layer.borderWidth = 1
layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor
addSubview(textField)
addSubview(unitLabel)
let focusTap = UITapGestureRecognizer(target: self, action: #selector(focusInput))
focusTap.cancelsTouchesInView = false
addGestureRecognizer(focusTap)
unitLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(8)
make.centerY.equalToSuperview()
}
textField.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.trailing.equalTo(unitLabel.snp.leading).offset(-8)
make.top.bottom.equalToSuperview()
}
snp.makeConstraints { $0.height.equalTo(52) }
}
@available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setDisplayValue(_ value: String) {
if !textField.isFirstResponder && !invalid { textField.text = value }
}
func setInvalid(_ value: Bool) {
invalid = value
layer.borderColor = (value ? AppColor.danger : ScenicQueueTokens.fieldBorderMuted).cgColor
}
@objc private func focusInput() { textField.becomeFirstResponder() }
func textFieldDidBeginEditing(_ textField: UITextField) {
layer.borderColor = ScenicQueueTokens.bannerBlue.cgColor
}
func textFieldDidEndEditing(_ textField: UITextField) {
let ok = delegate?.numericFieldView(self, commit: textField.text ?? "", key: key) ?? false
setInvalid(!ok)
}
}
///
final class ScenicQueueSwitchRow: UIView {
let toggle = UISwitch()
init(title: String) {
super.init(frame: .zero)
let label = UILabel()
label.text = title
label.font = ScenicQueueTokens.settingsRowTitleFont
label.textColor = AppColor.textPrimary
label.numberOfLines = 0
toggle.onTintColor = ScenicQueueTokens.bannerBlue
addSubview(label)
addSubview(toggle)
label.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.trailing.lessThanOrEqualTo(toggle.snp.leading).offset(-12)
}
toggle.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() }
snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
}
@available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setValue(_ value: Bool) { toggle.setOn(value, animated: false) }
}
///
final class ScenicQueuePresetVoiceRow: UIView {
var onPlay: (() -> Void)?
var onDelete: (() -> Void)?
private let content = UIView()
private let deleteButton = UIButton(type: .system)
private var revealed = false
init(text: String, playing: Bool) {
super.init(frame: .zero)
layer.cornerRadius = ScenicQueueTokens.radiusInput
clipsToBounds = true
deleteButton.setTitle("删除", for: .normal)
deleteButton.setTitleColor(.white, for: .normal)
deleteButton.backgroundColor = UIColor(hex: 0xE53935)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
content.backgroundColor = .white
content.layer.borderWidth = 1
content.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
content.layer.cornerRadius = ScenicQueueTokens.radiusInput
let play = UIButton(type: .system)
play.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
play.tintColor = ScenicQueueTokens.bannerBlue
play.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
let label = UILabel()
label.text = text
label.font = ScenicQueueTokens.settingsRowTitleFont
label.textColor = AppColor.textPrimary
label.numberOfLines = 3
addSubview(deleteButton)
addSubview(content)
content.addSubview(play)
content.addSubview(label)
deleteButton.snp.makeConstraints { make in
make.trailing.top.bottom.equalToSuperview()
make.width.equalTo(80)
}
content.snp.makeConstraints { $0.edges.equalToSuperview() }
play.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(4)
make.centerY.equalToSuperview()
make.size.equalTo(44)
}
label.snp.makeConstraints { make in
make.leading.equalTo(play.snp.trailing).offset(4)
make.trailing.equalToSuperview().offset(-12)
make.top.bottom.equalToSuperview().inset(12)
}
addGestureRecognizer(UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:))))
(gestureRecognizers?.last as? UISwipeGestureRecognizer)?.direction = .left
let right = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
right.direction = .right
addGestureRecognizer(right)
snp.makeConstraints { $0.height.greaterThanOrEqualTo(52) }
}
@available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func playTapped() { onPlay?() }
@objc private func deleteTapped() { onDelete?() }
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
revealed = gesture.direction == .left
content.snp.updateConstraints { make in
make.leading.equalToSuperview().offset(revealed ? -80 : 0)
make.trailing.equalToSuperview().offset(revealed ? -80 : 0)
}
UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() }
}
}
/// Android
final class ScenicQueueTimePickerViewController: UIViewController {
private let onConfirm: (String) -> Void
private let picker = UIDatePicker()
init(initialTime: String, onConfirm: @escaping (String) -> Void) {
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
picker.date = formatter.date(from: initialTime) ?? Date()
}
@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.radiusDialog
picker.datePickerMode = .time
picker.preferredDatePickerStyle = .wheels
picker.locale = Locale(identifier: "zh_CN")
let cancel = button("取消", filled: false, action: #selector(cancelTapped))
let confirm = button("确认", filled: true, action: #selector(confirmTapped))
let row = UIStackView(arrangedSubviews: [cancel, confirm])
row.axis = .horizontal
row.spacing = 12
row.distribution = .fillEqually
view.addSubview(card)
card.addSubview(picker)
card.addSubview(row)
card.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
picker.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(190)
}
row.snp.makeConstraints { make in
make.top.equalTo(picker.snp.bottom)
make.leading.trailing.bottom.equalToSuperview().inset(20)
make.height.equalTo(44)
}
}
private func button(_ title: String, filled: Bool, action: Selector) -> UIButton {
let b = UIButton(type: .system)
b.setTitle(title, for: .normal)
b.setTitleColor(filled ? .white : ScenicQueueTokens.bannerBlue, for: .normal)
b.backgroundColor =
filled ? ScenicQueueTokens.bannerBlue : ScenicQueueTokens.dialogCancelBackground
b.layer.cornerRadius = 8
b.addTarget(self, action: action, for: .touchUpInside)
return b
}
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func confirmTapped() {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
let value = formatter.string(from: picker.date)
dismiss(animated: true) { self.onConfirm(value) }
}
}