519 lines
23 KiB
Swift
519 lines
23 KiB
Swift
//
|
||
// ScenicQueueSettingsViewModel.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// 排队设置页 ViewModel,对齐 Android `ScenicQueueSettingsViewModel`。
|
||
final class ScenicQueueSettingsViewModel {
|
||
private enum Limits {
|
||
static let remarkMaxLen = 255
|
||
static let shootMinute = 0...30
|
||
static let shootSecond = 0...59
|
||
static let ahead = 0...50
|
||
static let broadcastInterval = 40...60
|
||
static let readableThreshold = 10...30
|
||
static let queueDistance = 0...9_999_990
|
||
static let queueTakeLimit = 0...999_999
|
||
static let missCallRequeueOffset = 1...9_999
|
||
static let autoCallAheadCount = 0...5
|
||
}
|
||
|
||
private(set) var punchSpotOptions: [ScenicQueuePunchSpotOption] = []
|
||
private(set) var selectedPunchSpotId: String?
|
||
private(set) var selectedPunchSpotLabel: String
|
||
private(set) var shootMinute: Int
|
||
private(set) var shootSecond: Int
|
||
private(set) var firstAheadCount: Int
|
||
private(set) var firstSms: Bool
|
||
private(set) var firstPhone: Bool
|
||
private(set) var secondAheadCount: Int
|
||
private(set) var secondSms: Bool
|
||
private(set) var secondPhone: Bool
|
||
private(set) var broadcastIntervalSec: Int
|
||
private(set) var countdownThresholdSec: Int
|
||
private(set) var queueDistanceMeter: Int
|
||
private(set) var queueTakeLimit: Int
|
||
private(set) var missCallRequeueOffset: Int
|
||
private(set) var showStartShootingButton: Bool
|
||
private(set) var autoCallAheadCount: Int
|
||
private(set) var quickCallButtonEnabled: Bool
|
||
private(set) var prepareCallButtonEnabled: Bool
|
||
private(set) var businessOpen: Bool
|
||
private(set) var businessStartTime: String
|
||
private(set) var businessEndTime: String
|
||
private(set) var customTTSText: String
|
||
private(set) var presetVoices: [String]
|
||
private(set) var settingsTab: ScenicQueueSettingsTab = .basic
|
||
private(set) var highlightedInvalidFieldKeys: Set<String> = []
|
||
|
||
var onStateChange: (() -> Void)?
|
||
var onShowMessage: ((String) -> Void)?
|
||
var onNavigateBack: (() -> Void)?
|
||
var onNavigateLog: (() -> Void)?
|
||
|
||
private let appStore: AppStore
|
||
private let ttsManager: ScenicQueueTTSManaging
|
||
private var punchSpotSettingRequestGeneration = 0
|
||
private var ttsLoopAnchorText: String?
|
||
|
||
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
|
||
self.appStore = appStore
|
||
self.ttsManager = ttsManager
|
||
let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot()
|
||
selectedPunchSpotId = appStore.scenicQueuePunchSpotId.nonEmptyTrimmed
|
||
selectedPunchSpotLabel = appStore.scenicQueuePunchSpotName
|
||
shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute)
|
||
shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond)
|
||
firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead)
|
||
firstSms = snapshot.firstSms
|
||
firstPhone = snapshot.firstPhone
|
||
secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead)
|
||
secondSms = snapshot.secondSms
|
||
secondPhone = snapshot.secondPhone
|
||
broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval)
|
||
countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold)
|
||
queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance)
|
||
queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit)
|
||
missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset)
|
||
showStartShootingButton = snapshot.showStartShootingButton
|
||
autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount)
|
||
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
|
||
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
|
||
businessOpen = snapshot.businessOpen
|
||
businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00")
|
||
businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00")
|
||
customTTSText = appStore.scenicQueueRemark
|
||
presetVoices = appStore.scenicQueuePresetVoices()
|
||
}
|
||
|
||
var selectedPunchSpotDisplayText: String {
|
||
if let selectedPunchSpotId,
|
||
let option = punchSpotOptions.first(where: { $0.id == selectedPunchSpotId }),
|
||
!option.label.isEmpty {
|
||
return option.label
|
||
}
|
||
if !selectedPunchSpotLabel.isEmpty {
|
||
return selectedPunchSpotLabel
|
||
}
|
||
if let selectedPunchSpotId, !selectedPunchSpotId.isEmpty {
|
||
return "打卡点(ID \(selectedPunchSpotId))"
|
||
}
|
||
return "请选择打卡点"
|
||
}
|
||
|
||
/// 是否优先使用离线语音合成。
|
||
var useOfflineTTS: Bool {
|
||
appStore.scenicQueueUseOfflineTTS
|
||
}
|
||
|
||
func openSettingChangeLog() {
|
||
onNavigateLog?()
|
||
}
|
||
|
||
/// 打开打卡点选择器前加载全部打卡点。
|
||
func loadPunchSpotOptions(api: ScenicQueueAPIProtocol) async {
|
||
guard appStore.currentScenicId > 0 else {
|
||
onShowMessage?("请先选择景区")
|
||
return
|
||
}
|
||
do {
|
||
let response = try await api.scenicSpotListAll(scenicId: String(appStore.currentScenicId))
|
||
punchSpotOptions = response.list.map {
|
||
ScenicQueuePunchSpotOption(id: String($0.id), label: $0.name)
|
||
}
|
||
if punchSpotOptions.isEmpty {
|
||
onShowMessage?("暂无可选打卡点")
|
||
}
|
||
notifyStateChange()
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 选择打卡点并尝试拉取服务端设置。
|
||
func setPunchSpot(_ option: ScenicQueuePunchSpotOption, api: ScenicQueueAPIProtocol) async {
|
||
selectedPunchSpotId = option.id
|
||
selectedPunchSpotLabel = option.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
highlightedInvalidFieldKeys.remove("punchSpot")
|
||
notifyStateChange()
|
||
await fetchQueueSettingAfterPunchSpotSelected(option.id, api: api)
|
||
}
|
||
|
||
func setSettingsTab(_ tab: ScenicQueueSettingsTab) {
|
||
settingsTab = tab
|
||
notifyStateChange()
|
||
}
|
||
|
||
func setShootMinute(_ value: Int) { shootMinute = value.clamped(to: Limits.shootMinute); notifyStateChange() }
|
||
func setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); notifyStateChange() }
|
||
func setFirstAheadCount(_ value: Int) { firstAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
|
||
func setSecondAheadCount(_ value: Int) { secondAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
|
||
func setBroadcastIntervalSec(_ value: Int) { broadcastIntervalSec = value.clamped(to: Limits.broadcastInterval); notifyStateChange() }
|
||
func setCountdownThresholdSec(_ value: Int) { countdownThresholdSec = value.clamped(to: Limits.readableThreshold); notifyStateChange() }
|
||
func setQueueDistanceMeter(_ value: Int) { queueDistanceMeter = value.clamped(to: Limits.queueDistance); notifyStateChange() }
|
||
func setQueueTakeLimit(_ value: Int) { queueTakeLimit = value.clamped(to: Limits.queueTakeLimit); notifyStateChange() }
|
||
func setMissCallRequeueOffset(_ value: Int) { missCallRequeueOffset = value.clamped(to: Limits.missCallRequeueOffset); notifyStateChange() }
|
||
func setAutoCallAheadCount(_ value: Int) { autoCallAheadCount = value.clamped(to: Limits.autoCallAheadCount); notifyStateChange() }
|
||
func toggleShowStartShootingButton() { showStartShootingButton.toggle(); notifyStateChange() }
|
||
func toggleQuickCallButtonEnabled() { quickCallButtonEnabled.toggle(); notifyStateChange() }
|
||
func togglePrepareCallButtonEnabled() { prepareCallButtonEnabled.toggle(); notifyStateChange() }
|
||
func toggleBusinessOpen() { businessOpen.toggle(); notifyStateChange() }
|
||
func toggleFirstSms() { firstSms.toggle(); notifyStateChange() }
|
||
func toggleFirstPhone() { firstPhone.toggle(); notifyStateChange() }
|
||
func toggleSecondSms() { secondSms.toggle(); notifyStateChange() }
|
||
func toggleSecondPhone() { secondPhone.toggle(); notifyStateChange() }
|
||
|
||
func setBusinessStartTime(_ value: String) {
|
||
businessStartTime = Self.normalizedTime(value, fallback: businessStartTime)
|
||
notifyStateChange()
|
||
}
|
||
|
||
func setBusinessEndTime(_ value: String) {
|
||
businessEndTime = Self.normalizedTime(value, fallback: businessEndTime)
|
||
notifyStateChange()
|
||
}
|
||
|
||
/// 设置离线语音合成偏好。
|
||
func setUseOfflineTTS(_ enabled: Bool) {
|
||
guard enabled else {
|
||
appStore.scenicQueueUseOfflineTTS = false
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
guard ttsManager.supportsOfflineTTS else {
|
||
appStore.scenicQueueUseOfflineTTS = false
|
||
onShowMessage?("离线语音资源未安装")
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
appStore.scenicQueueUseOfflineTTS = true
|
||
notifyStateChange()
|
||
}
|
||
|
||
func setCustomTTSText(_ value: String) {
|
||
customTTSText = String(value.prefix(Limits.remarkMaxLen))
|
||
notifyStateChange()
|
||
}
|
||
|
||
/// 播放或停止自定义 TTS 文案。
|
||
func toggleCustomTTSPlayback() {
|
||
if ttsManager.customTextLooping {
|
||
ttsManager.stopCustomTextLoop()
|
||
ttsLoopAnchorText = nil
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty else {
|
||
onShowMessage?("请输入要播报的文字")
|
||
return
|
||
}
|
||
Task {
|
||
let ok = await ttsManager.startCustomTextLoop(text)
|
||
if ok {
|
||
ttsLoopAnchorText = text
|
||
} else {
|
||
ttsLoopAnchorText = nil
|
||
onShowMessage?("播报失败,请检查网络与语音服务")
|
||
}
|
||
notifyStateChange()
|
||
}
|
||
}
|
||
|
||
/// 播放蓝牙测试音。
|
||
func playBluetoothTestSound() {
|
||
Task {
|
||
let ok = await ttsManager.speakPlainTextOnce("蓝牙音响测试音,请确认外放音量是否合适。")
|
||
if !ok {
|
||
onShowMessage?("测试音播放失败,请检查网络与语音服务")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 保存当前文案到本地预设。
|
||
func saveCurrentTextAsPresetVoiceLocal() {
|
||
guard appStore.currentScenicId > 0, selectedPunchSpotId?.nonEmptyTrimmed != nil else {
|
||
onShowMessage?("请先选择打卡点")
|
||
return
|
||
}
|
||
let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty else {
|
||
onShowMessage?("请输入要播报的文字")
|
||
return
|
||
}
|
||
if presetVoices.count >= 5 {
|
||
onShowMessage?("最多保存5个语音")
|
||
return
|
||
}
|
||
if presetVoices.contains(text) {
|
||
onShowMessage?("该文案已在列表中")
|
||
return
|
||
}
|
||
presetVoices.append(text)
|
||
appStore.saveScenicQueuePresetVoices(presetVoices)
|
||
onShowMessage?("已保存到本地")
|
||
notifyStateChange()
|
||
}
|
||
|
||
func deletePresetVoice(at index: Int) {
|
||
guard presetVoices.indices.contains(index) else { return }
|
||
let removed = presetVoices.remove(at: index)
|
||
appStore.saveScenicQueuePresetVoices(presetVoices)
|
||
if ttsLoopAnchorText == removed {
|
||
ttsManager.stopCustomTextLoop()
|
||
ttsLoopAnchorText = nil
|
||
}
|
||
notifyStateChange()
|
||
}
|
||
|
||
/// 保存排队打卡点二维码到相册。
|
||
func saveShootQueueQRCode(api: ScenicQueueAPIProtocol) async {
|
||
guard let ids = currentScenicAndSpotIdsForSelected() else {
|
||
onShowMessage?("请先选择打卡点")
|
||
return
|
||
}
|
||
do {
|
||
let data = try await api.shootQueueQrcode(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
|
||
guard let url = data.qrcodeUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty else {
|
||
onShowMessage?("服务端未返回二维码图片地址")
|
||
return
|
||
}
|
||
try await ScenicQueueQRCodeSaver.saveImage(from: url)
|
||
onShowMessage?("保存成功")
|
||
} catch {
|
||
onShowMessage?((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 校验并保存设置。
|
||
func save(api: ScenicQueueAPIProtocol) async {
|
||
highlightedInvalidFieldKeys = []
|
||
guard let ids = currentScenicAndSpotIdsForSelected() else {
|
||
highlightedInvalidFieldKeys = ["punchSpot"]
|
||
settingsTab = .basic
|
||
onShowMessage?("请选择打卡点")
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
let validation = buildSaveValidation()
|
||
guard validation.messages.isEmpty else {
|
||
highlightedInvalidFieldKeys = validation.keys
|
||
settingsTab = tabForValidationKeys(validation.keys)
|
||
onShowMessage?(validation.messages.joined(separator: "\n"))
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
let remark = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let request = ScenicQueueSaveSettingRequest(
|
||
scenicId: ids.scenicId,
|
||
scenicSpotId: ids.scenicSpotId,
|
||
photoEstimateMin: shootMinute,
|
||
photoEstimateSec: shootSecond,
|
||
firstNoticeThresholdPos: firstAheadCount,
|
||
firstNoticeSmsEnabled: firstSms ? 1 : 0,
|
||
firstNoticeCallEnabled: firstPhone ? 1 : 0,
|
||
secondNoticeThresholdPos: secondAheadCount,
|
||
secondNoticeSmsEnabled: secondSms ? 1 : 0,
|
||
secondNoticeCallEnabled: secondPhone ? 1 : 0,
|
||
countdownBroadcastIntervalSec: broadcastIntervalSec,
|
||
countdownReadableThresholdSec: countdownThresholdSec,
|
||
queueDistanceMeter: queueDistanceMeter,
|
||
queueTakeLimit: queueTakeLimit,
|
||
missCallRequeueOffset: missCallRequeueOffset,
|
||
showStartShootButton: showStartShootingButton ? 1 : 0,
|
||
autoCallNextCount: autoCallAheadCount,
|
||
businessStartTime: businessStartTime,
|
||
businessEndTime: businessEndTime,
|
||
voiceBroadcasts: presetVoices.enumerated().map { ScenicQueueVoiceBroadcastItem(content: $0.element, sortOrder: $0.offset + 1) },
|
||
status: businessOpen ? 1 : 0,
|
||
remark: remark.isEmpty ? nil : remark
|
||
)
|
||
do {
|
||
try await api.saveSetting(request)
|
||
appStore.scenicQueuePunchSpotId = String(ids.scenicSpotId)
|
||
appStore.scenicQueuePunchSpotName = selectedPunchSpotLabel
|
||
appStore.scenicQueueRemark = remark
|
||
appStore.saveScenicQueuePresetVoices(presetVoices)
|
||
appStore.saveScenicQueueSettingsSnapshot(currentSnapshot())
|
||
_ = try? await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
|
||
onShowMessage?("保存成功")
|
||
onNavigateBack?()
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func fetchQueueSettingAfterPunchSpotSelected(_ spotId: String, api: ScenicQueueAPIProtocol) async {
|
||
guard let scenicId = Int64("\(appStore.currentScenicId)"),
|
||
let scenicSpotId = Int64(spotId),
|
||
scenicId > 0,
|
||
scenicSpotId > 0 else { return }
|
||
punchSpotSettingRequestGeneration += 1
|
||
let generation = punchSpotSettingRequestGeneration
|
||
do {
|
||
let data = try await api.setting(scenicId: scenicId, scenicSpotId: scenicSpotId)
|
||
guard generation == punchSpotSettingRequestGeneration,
|
||
selectedPunchSpotId == spotId,
|
||
data.exists,
|
||
let setting = data.setting else { return }
|
||
applyServerSettingToForm(setting)
|
||
} catch {
|
||
// Keep local form values if remote setting cannot be loaded.
|
||
}
|
||
}
|
||
|
||
private func applyServerSettingToForm(_ setting: ScenicQueueSettingItem) {
|
||
let fallback = ScenicQueueSettingsSnapshot(
|
||
showStartShootingButton: showStartShootingButton,
|
||
autoCallAheadCount: autoCallAheadCount,
|
||
quickCallButtonEnabled: quickCallButtonEnabled,
|
||
prepareCallButtonEnabled: prepareCallButtonEnabled
|
||
)
|
||
let snapshot = setting.toSnapshot(fallback: fallback)
|
||
apply(snapshot)
|
||
customTTSText = String((setting.remark ?? "").prefix(Limits.remarkMaxLen))
|
||
if let voices = setting.voiceBroadcasts {
|
||
presetVoices = voices
|
||
.sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) }
|
||
.compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed }
|
||
.prefix(5)
|
||
.map { $0 }
|
||
}
|
||
if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed {
|
||
selectedPunchSpotLabel = name
|
||
}
|
||
highlightedInvalidFieldKeys = []
|
||
notifyStateChange()
|
||
}
|
||
|
||
private func apply(_ snapshot: ScenicQueueSettingsSnapshot) {
|
||
shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute)
|
||
shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond)
|
||
firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead)
|
||
firstSms = snapshot.firstSms
|
||
firstPhone = snapshot.firstPhone
|
||
secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead)
|
||
secondSms = snapshot.secondSms
|
||
secondPhone = snapshot.secondPhone
|
||
broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval)
|
||
countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold)
|
||
queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance)
|
||
queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit)
|
||
missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset)
|
||
showStartShootingButton = snapshot.showStartShootingButton
|
||
autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount)
|
||
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
|
||
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
|
||
businessOpen = snapshot.businessOpen
|
||
businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00")
|
||
businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00")
|
||
}
|
||
|
||
private func currentSnapshot() -> ScenicQueueSettingsSnapshot {
|
||
ScenicQueueSettingsSnapshot(
|
||
shootMinute: shootMinute,
|
||
shootSecond: shootSecond,
|
||
firstAheadCount: firstAheadCount,
|
||
firstSms: firstSms,
|
||
firstPhone: firstPhone,
|
||
secondAheadCount: secondAheadCount,
|
||
secondSms: secondSms,
|
||
secondPhone: secondPhone,
|
||
broadcastIntervalSec: broadcastIntervalSec,
|
||
countdownThresholdSec: countdownThresholdSec,
|
||
queueDistanceMeter: queueDistanceMeter,
|
||
queueTakeLimit: queueTakeLimit,
|
||
missCallRequeueOffset: missCallRequeueOffset,
|
||
showStartShootingButton: showStartShootingButton,
|
||
autoCallAheadCount: autoCallAheadCount,
|
||
quickCallButtonEnabled: quickCallButtonEnabled,
|
||
prepareCallButtonEnabled: prepareCallButtonEnabled,
|
||
businessOpen: businessOpen,
|
||
businessStartTime: businessStartTime,
|
||
businessEndTime: businessEndTime
|
||
)
|
||
}
|
||
|
||
private func buildSaveValidation() -> (messages: [String], keys: Set<String>) {
|
||
var messages: [String] = []
|
||
var keys: Set<String> = []
|
||
if !Limits.shootMinute.contains(shootMinute) {
|
||
messages.append("拍摄时长:分钟须在 0~30,且按 1 分钟递增")
|
||
keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue)
|
||
}
|
||
if !Limits.shootSecond.contains(shootSecond) {
|
||
messages.append("拍摄时长:秒须在 0~59")
|
||
keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue)
|
||
}
|
||
if shootMinute == 0 && shootSecond == 0 {
|
||
messages.append("拍摄时长须大于 0")
|
||
keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue)
|
||
keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue)
|
||
}
|
||
if !Limits.broadcastInterval.contains(broadcastIntervalSec) {
|
||
messages.append("播报间隔时间须在 40~60 秒")
|
||
keys.insert(ScenicQueueNumericFieldKey.broadcastIntervalSec.rawValue)
|
||
}
|
||
if !Limits.readableThreshold.contains(countdownThresholdSec) {
|
||
messages.append("进入读秒倒计时阈值须在 10~30 秒")
|
||
keys.insert(ScenicQueueNumericFieldKey.countdownReadableThresholdSec.rawValue)
|
||
}
|
||
return (messages, keys)
|
||
}
|
||
|
||
private func tabForValidationKeys(_ keys: Set<String>) -> ScenicQueueSettingsTab {
|
||
if keys.contains("punchSpot")
|
||
|| keys.contains(ScenicQueueNumericFieldKey.queueDistanceMeter.rawValue)
|
||
|| keys.contains(ScenicQueueNumericFieldKey.shootMinute.rawValue)
|
||
|| keys.contains(ScenicQueueNumericFieldKey.shootSecond.rawValue)
|
||
|| keys.contains(ScenicQueueNumericFieldKey.queueTakeLimit.rawValue)
|
||
|| keys.contains(ScenicQueueNumericFieldKey.missCallRequeueOffset.rawValue) {
|
||
return .basic
|
||
}
|
||
if keys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue)
|
||
|| keys.contains(ScenicQueueNumericFieldKey.secondNoticeAhead.rawValue) {
|
||
return .notify
|
||
}
|
||
return .voice
|
||
}
|
||
|
||
private func currentScenicAndSpotIdsForSelected() -> (scenicId: Int64, scenicSpotId: Int64)? {
|
||
guard let scenicId = Int64("\(appStore.currentScenicId)"), scenicId > 0,
|
||
let spotIdText = selectedPunchSpotId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
let scenicSpotId = Int64(spotIdText), scenicSpotId > 0 else { return nil }
|
||
return (scenicId, scenicSpotId)
|
||
}
|
||
|
||
private static func normalizedTime(_ raw: String, fallback: String) -> String {
|
||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "zh_CN")
|
||
for format in ["HH:mm", "HH:mm:ss"] {
|
||
formatter.dateFormat = format
|
||
if let date = formatter.date(from: text) {
|
||
formatter.dateFormat = "HH:mm"
|
||
return formatter.string(from: date)
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
private func notifyStateChange() {
|
||
onStateChange?()
|
||
}
|
||
}
|
||
|
||
private extension Int {
|
||
func clamped(to range: ClosedRange<Int>) -> Int {
|
||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var nonEmptyTrimmed: String? {
|
||
isEmpty ? nil : self
|
||
}
|
||
}
|