Files
suixinkan_uikit/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift
2026-07-07 15:32:25 +08:00

428 lines
21 KiB
Swift
Raw 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` Tab
final class ScenicQueueSettingsViewController: BaseViewController {
private let viewModel = ScenicQueueSettingsViewModel()
private let api: ScenicQueueAPIProtocol
private let segmentedControl = UISegmentedControl(items: ScenicQueueSettingsTab.allCases.map(\.title))
private let scrollView = UIScrollView()
private let stackView = UIStackView()
private let saveBar = UIView()
private let saveButton = UIButton(type: .system)
private var fieldRows: [String: ScenicQueueSettingFieldRow] = [:]
private var switchRows: [String: ScenicQueueSwitchRow] = [:]
@MainActor
init(api: ScenicQueueAPIProtocol? = nil) {
self.api = api ?? NetworkServices.shared.scenicQueueAPI
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
segmentedControl.selectedSegmentIndex = viewModel.settingsTab.rawValue
segmentedControl.selectedSegmentTintColor = .white
segmentedControl.setTitleTextAttributes([
.font: ScenicQueueTokens.settingsRowTitleFont,
.foregroundColor: AppColor.textSecondary,
], for: .normal)
segmentedControl.setTitleTextAttributes([
.font: UIFont.systemFont(ofSize: 14, weight: .semibold),
.foregroundColor: ScenicQueueTokens.bannerBlue,
], for: .selected)
stackView.axis = .vertical
stackView.spacing = 14
scrollView.keyboardDismissMode = .onDrag
scrollView.alwaysBounceVertical = true
saveBar.backgroundColor = .white
saveButton.setTitle("保存", for: .normal)
saveButton.setTitleColor(.white, for: .normal)
saveButton.titleLabel?.font = ScenicQueueTokens.settingsSaveButtonFont
saveButton.backgroundColor = ScenicQueueTokens.bannerBlue
saveButton.layer.cornerRadius = ScenicQueueTokens.bottomSaveButtonRadius
saveButton.clipsToBounds = true
view.addSubview(segmentedControl)
view.addSubview(scrollView)
scrollView.addSubview(stackView)
view.addSubview(saveBar)
saveBar.addSubview(saveButton)
rebuildForm()
}
override func setupConstraints() {
segmentedControl.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(40)
}
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.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
make.height.equalTo(48)
}
scrollView.snp.makeConstraints { make in
make.top.equalTo(segmentedControl.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(saveBar.snp.top)
}
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 16, right: 16))
make.width.equalToSuperview().offset(-32)
}
}
override func bindActions() {
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
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 applyState() {
segmentedControl.selectedSegmentIndex = viewModel.settingsTab.rawValue
rebuildForm()
}
private func rebuildForm() {
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
fieldRows.removeAll()
switchRows.removeAll()
switch viewModel.settingsTab {
case .basic:
buildBasicForm()
case .notify:
buildNotifyForm()
case .voice:
buildVoiceForm()
}
}
private func buildBasicForm() {
stackView.addArrangedSubview(tipCard())
stackView.addArrangedSubview(actionCard(title: "打卡点", value: viewModel.selectedPunchSpotDisplayText, primary: "选择打卡点", secondary: "保存小程序码", primaryAction: #selector(punchSpotTapped), secondaryAction: #selector(saveQRTapped), invalid: viewModel.highlightedInvalidFieldKeys.contains("punchSpot")))
addSwitchRow(key: "businessOpen", title: "营业状态", isOn: viewModel.businessOpen, action: #selector(businessOpenChanged(_:)))
addTextRow(key: "businessStart", title: "营业开始时间", value: viewModel.businessStartTime, suffix: "HH:mm", keyboard: .numbersAndPunctuation, action: #selector(businessStartChanged(_:)))
addTextRow(key: "businessEnd", title: "营业结束时间", value: viewModel.businessEndTime, suffix: "HH:mm", keyboard: .numbersAndPunctuation, action: #selector(businessEndChanged(_:)))
addTextRow(key: "queueDistance", title: "允许排队距离", value: "\(viewModel.queueDistanceMeter)", suffix: "", keyboard: .numberPad, action: #selector(queueDistanceChanged(_:)))
addTextRow(key: "shootMinute", title: "拍摄时长(分钟)", value: "\(viewModel.shootMinute)", suffix: "", keyboard: .numberPad, action: #selector(shootMinuteChanged(_:)))
addTextRow(key: "shootSecond", title: "拍摄时长(秒)", value: "\(viewModel.shootSecond)", suffix: "", keyboard: .numberPad, action: #selector(shootSecondChanged(_:)))
addTextRow(key: "queueTakeLimit", title: "单人取号次数", value: "\(viewModel.queueTakeLimit)", suffix: "", keyboard: .numberPad, action: #selector(queueTakeLimitChanged(_:)))
addTextRow(key: "missOffset", title: "过号重排顺延位数", value: "\(viewModel.missCallRequeueOffset)", suffix: "", keyboard: .numberPad, action: #selector(missOffsetChanged(_:)))
addSwitchRow(key: "showShoot", title: "展示开始拍摄按钮", isOn: viewModel.showStartShootingButton, action: #selector(showShootChanged(_:)))
addTextRow(key: "autoCall", title: "自动提前叫号数量", value: "\(viewModel.autoCallAheadCount)", suffix: "", keyboard: .numberPad, action: #selector(autoCallChanged(_:)))
addSwitchRow(key: "quickCall", title: "底部快速叫号", isOn: viewModel.quickCallButtonEnabled, action: #selector(quickCallChanged(_:)))
addSwitchRow(key: "prepareCall", title: "底部准备叫号", isOn: viewModel.prepareCallButtonEnabled, action: #selector(prepareCallChanged(_:)))
}
private func buildNotifyForm() {
addTextRow(key: "firstAhead", title: "第一次提前通知阈值", value: "\(viewModel.firstAheadCount)", suffix: "", keyboard: .numberPad, action: #selector(firstAheadChanged(_:)))
addSwitchRow(key: "firstSms", title: "第一次短信通知", isOn: viewModel.firstSms, action: #selector(firstSmsChanged(_:)))
addSwitchRow(key: "firstPhone", title: "第一次电话通知", isOn: viewModel.firstPhone, action: #selector(firstPhoneChanged(_:)))
addTextRow(key: "secondAhead", title: "第二次提前通知阈值", value: "\(viewModel.secondAheadCount)", suffix: "", keyboard: .numberPad, action: #selector(secondAheadChanged(_:)))
addSwitchRow(key: "secondSms", title: "第二次短信通知", isOn: viewModel.secondSms, action: #selector(secondSmsChanged(_:)))
addSwitchRow(key: "secondPhone", title: "第二次电话通知", isOn: viewModel.secondPhone, action: #selector(secondPhoneChanged(_:)))
}
private func buildVoiceForm() {
addTextRow(key: "broadcastInterval", title: "播报间隔时间", value: "\(viewModel.broadcastIntervalSec)", suffix: "", keyboard: .numberPad, action: #selector(broadcastChanged(_:)))
addTextRow(key: "countdown", title: "进入读秒倒计时阈值", value: "\(viewModel.countdownThresholdSec)", suffix: "", keyboard: .numberPad, action: #selector(countdownChanged(_:)))
stackView.addArrangedSubview(actionCard(title: "蓝牙音响测试", value: "请确认外放音量是否合适", primary: "播放测试音", secondary: nil, primaryAction: #selector(testSoundTapped), secondaryAction: nil))
addTextRow(key: "customTTS", title: "自定义播报文案", value: viewModel.customTTSText, suffix: "", keyboard: .default, action: #selector(customTTSChanged(_:)))
stackView.addArrangedSubview(actionCard(title: "播报文案", value: "最多保存5个语音", primary: "播放文本", secondary: "保存到预设", primaryAction: #selector(playCustomTapped), secondaryAction: #selector(savePresetTapped)))
for (index, phrase) in viewModel.presetVoices.enumerated() {
stackView.addArrangedSubview(presetVoiceCard(index: index, phrase: phrase))
}
}
private func addTextRow(key: String, title: String, value: String, suffix: String, keyboard: UIKeyboardType, action: Selector) {
let row = ScenicQueueSettingFieldRow(title: title, value: value, suffix: suffix, keyboard: keyboard)
row.textField.addTarget(self, action: action, for: .editingChanged)
fieldRows[key] = row
stackView.addArrangedSubview(row)
}
private func addSwitchRow(key: String, title: String, isOn: Bool, action: Selector) {
let row = ScenicQueueSwitchRow(title: title, isOn: isOn)
row.toggle.addTarget(self, action: action, for: .valueChanged)
switchRows[key] = row
stackView.addArrangedSubview(row)
}
private func tipCard() -> UIView {
let card = ScenicQueueSettingCard()
card.backgroundColor = ScenicQueueTokens.bannerBlue
let title = UILabel()
title.text = "排队基础参数"
title.textColor = .white
title.font = ScenicQueueTokens.settingsSectionTitleFont
let body = UILabel()
body.text = "选择打卡点后保存配置,顾客即可扫码取号排队。"
body.textColor = UIColor.white.withAlphaComponent(0.86)
body.font = ScenicQueueTokens.settingsHintFont
body.numberOfLines = 0
let stack = UIStackView(arrangedSubviews: [title, body])
stack.axis = .vertical
stack.spacing = 6
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(14)
}
return card
}
private func actionCard(title: String, value: String, primary: String, secondary: String?, primaryAction: Selector, secondaryAction: Selector?, invalid: Bool = false) -> UIView {
let card = ScenicQueueSettingCard()
card.layer.borderColor = invalid ? AppColor.danger.cgColor : ScenicQueueTokens.cardOutline.cgColor
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = ScenicQueueTokens.settingsFieldLabelFont
titleLabel.textColor = AppColor.textPrimary
let valueLabel = UILabel()
valueLabel.text = value
valueLabel.font = ScenicQueueTokens.settingsHintFont
valueLabel.textColor = AppColor.textSecondary
valueLabel.numberOfLines = 0
let primaryButton = UIButton(type: .system)
primaryButton.setTitle(primary, for: .normal)
primaryButton.titleLabel?.font = ScenicQueueTokens.buttonSecondaryFont
primaryButton.addTarget(self, action: primaryAction, for: .touchUpInside)
let buttons = UIStackView(arrangedSubviews: [primaryButton])
buttons.axis = .horizontal
buttons.spacing = 12
if let secondary, let secondaryAction {
let secondaryButton = UIButton(type: .system)
secondaryButton.setTitle(secondary, for: .normal)
secondaryButton.titleLabel?.font = ScenicQueueTokens.buttonSecondaryFont
secondaryButton.addTarget(self, action: secondaryAction, for: .touchUpInside)
buttons.addArrangedSubview(secondaryButton)
}
let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel, buttons])
stack.axis = .vertical
stack.spacing = 8
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(14)
}
return card
}
private func presetVoiceCard(index: Int, phrase: String) -> UIView {
let card = ScenicQueueSettingCard()
let label = UILabel()
label.text = phrase
label.font = ScenicQueueTokens.settingsRowTitleFont
label.textColor = AppColor.textPrimary
label.numberOfLines = 0
let delete = UIButton(type: .system)
delete.setImage(UIImage(systemName: "trash"), for: .normal)
delete.tintColor = AppColor.danger
delete.tag = index
delete.addTarget(self, action: #selector(deletePresetTapped(_:)), for: .touchUpInside)
card.addSubview(label)
card.addSubview(delete)
label.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(14)
make.trailing.equalTo(delete.snp.leading).offset(-12)
}
delete.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(44)
}
return card
}
@objc private func tabChanged() {
viewModel.setSettingsTab(ScenicQueueSettingsTab(rawValue: segmentedControl.selectedSegmentIndex) ?? .basic)
}
@objc private func logTapped() { viewModel.openSettingChangeLog() }
@objc private func saveTapped() {
view.endEditing(true)
Task { await viewModel.save(api: api) }
}
@objc private func punchSpotTapped() {
Task {
await viewModel.loadPunchSpotOptions(api: api)
await MainActor.run { presentPunchSpotPicker() }
}
}
private func presentPunchSpotPicker() {
let alert = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
viewModel.punchSpotOptions.forEach { option in
alert.addAction(UIAlertAction(title: option.label, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.setPunchSpot(option, api: self.api) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func saveQRTapped() { Task { await viewModel.saveShootQueueQRCode(api: api) } }
@objc private func testSoundTapped() { viewModel.playBluetoothTestSound() }
@objc private func playCustomTapped() { viewModel.toggleCustomTTSPlayback() }
@objc private func savePresetTapped() { viewModel.saveCurrentTextAsPresetVoiceLocal() }
@objc private func deletePresetTapped(_ sender: UIButton) { viewModel.deletePresetVoice(at: sender.tag) }
@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() }
@objc private func businessStartChanged(_ sender: UITextField) { viewModel.setBusinessStartTime(sender.text ?? "") }
@objc private func businessEndChanged(_ sender: UITextField) { viewModel.setBusinessEndTime(sender.text ?? "") }
@objc private func queueDistanceChanged(_ sender: UITextField) { viewModel.setQueueDistanceMeter(Int(sender.text ?? "") ?? 0) }
@objc private func shootMinuteChanged(_ sender: UITextField) { viewModel.setShootMinute(Int(sender.text ?? "") ?? 0) }
@objc private func shootSecondChanged(_ sender: UITextField) { viewModel.setShootSecond(Int(sender.text ?? "") ?? 0) }
@objc private func queueTakeLimitChanged(_ sender: UITextField) { viewModel.setQueueTakeLimit(Int(sender.text ?? "") ?? 0) }
@objc private func missOffsetChanged(_ sender: UITextField) { viewModel.setMissCallRequeueOffset(Int(sender.text ?? "") ?? 1) }
@objc private func autoCallChanged(_ sender: UITextField) { viewModel.setAutoCallAheadCount(Int(sender.text ?? "") ?? 0) }
@objc private func firstAheadChanged(_ sender: UITextField) { viewModel.setFirstAheadCount(Int(sender.text ?? "") ?? 0) }
@objc private func secondAheadChanged(_ sender: UITextField) { viewModel.setSecondAheadCount(Int(sender.text ?? "") ?? 0) }
@objc private func broadcastChanged(_ sender: UITextField) { viewModel.setBroadcastIntervalSec(Int(sender.text ?? "") ?? 50) }
@objc private func countdownChanged(_ sender: UITextField) { viewModel.setCountdownThresholdSec(Int(sender.text ?? "") ?? 15) }
@objc private func customTTSChanged(_ sender: UITextField) { viewModel.setCustomTTSText(sender.text ?? "") }
}
///
class ScenicQueueSettingCard: UIView {
init() {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = ScenicQueueTokens.radiusCard
layer.borderWidth = 1
layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class ScenicQueueSettingFieldRow: ScenicQueueSettingCard {
let textField = UITextField()
init(title: String, value: String, suffix: String, keyboard: UIKeyboardType) {
super.init()
let label = UILabel()
label.text = title
label.font = ScenicQueueTokens.settingsFieldLabelFont
label.textColor = AppColor.textPrimary
textField.text = value
textField.keyboardType = keyboard
textField.textAlignment = .right
textField.font = ScenicQueueTokens.settingsRowTitleFont
textField.textColor = AppColor.textPrimary
let suffixLabel = UILabel()
suffixLabel.text = suffix
suffixLabel.font = ScenicQueueTokens.settingsHintFont
suffixLabel.textColor = AppColor.textSecondary
addSubview(label)
addSubview(textField)
addSubview(suffixLabel)
label.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14)
make.centerY.equalToSuperview()
}
suffixLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
}
textField.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12)
make.trailing.equalTo(suffixLabel.snp.leading).offset(-6)
make.top.bottom.equalToSuperview().inset(8)
make.height.equalTo(38)
make.width.greaterThanOrEqualTo(90)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class ScenicQueueSwitchRow: ScenicQueueSettingCard {
let toggle = UISwitch()
init(title: String, isOn: Bool) {
super.init()
let label = UILabel()
label.text = title
label.font = ScenicQueueTokens.settingsFieldLabelFont
label.textColor = AppColor.textPrimary
toggle.isOn = isOn
toggle.onTintColor = ScenicQueueTokens.bannerBlue
addSubview(label)
addSubview(toggle)
label.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14)
make.centerY.equalToSuperview()
make.top.bottom.equalToSuperview().inset(14)
}
toggle.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}