重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
929 lines
42 KiB
Swift
929 lines
42 KiB
Swift
//
|
||
// 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 表示不限制。合法范围 0~9999.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;分钟 0~30,秒 0~59。"), row]
|
||
})
|
||
page.stack.addArrangedSubview(
|
||
sectionCard(title: "单人取号次数限制") {
|
||
[
|
||
self.hintLabel("0 表示不限制,最大 999999 次。"),
|
||
self.numericField(key: "queueTakeLimit", unit: "次"),
|
||
]
|
||
})
|
||
page.stack.addArrangedSubview(
|
||
sectionCard(title: "过号重新排队") {
|
||
[
|
||
self.hintLabel("设置过号重新排队时相对队首顺延的位数,范围 1~9999。"),
|
||
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("开始拍摄后自动通知后续 0~5 个排队号。"), 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("前方人数达到阈值时触发,范围 0~50 位。"),
|
||
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("可设置更接近队首的二次提醒,范围 0~50 位。"),
|
||
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("从开拍起按间隔播报剩余时间,合法范围 40~60 秒。"),
|
||
self.numericField(key: "broadcastIntervalSec", unit: "秒"),
|
||
self.sectionLabel("进入读秒倒计时阈值"), self.hintLabel("到达阈值后逐秒播报,合法范围 10~30 秒。"),
|
||
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.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 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) }
|
||
}
|
||
}
|