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

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

577 lines
26 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.
//
// 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> = []
private(set) var blockingInvalidNumericFieldKeys: 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(set) var ttsLoopAnchorText: String?
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
self.appStore = appStore
self.ttsManager = ttsManager
let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()
selectedPunchSpotId = appStore.scenicQueue.punchSpotId.nonEmptyTrimmed
selectedPunchSpotLabel = appStore.scenicQueue.punchSpotName
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.scenicQueue.remark
presetVoices = appStore.scenicQueue.presetVoices()
}
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.scenicQueue.useOfflineTTS
}
/// 自定义文案或预设文案是否正在循环播报。
var isCustomTTSLooping: Bool {
ttsManager.customTextLooping
}
/// 将米格式化为 Android 设置页使用的千米文本,最多保留两位小数。
nonisolated static func kilometersDisplay(fromMeters meters: Int) -> String {
let value = Double(max(0, meters)) / 1_000
if value.rounded() == value { return String(format: "%.0f", value) }
let text = String(format: "%.2f", value)
return text.replacingOccurrences(of: #"0+$"#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"\.$"#, with: "", options: .regularExpression)
}
/// 将最多两位小数的千米文本转换为米;非法格式或越界时返回 nil。
nonisolated static func meters(fromKilometersText raw: String) -> Int? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.range(of: #"^\d{1,4}(\.\d{0,2})?$"#, options: .regularExpression) != nil,
let value = Decimal(string: text, locale: Locale(identifier: "en_US_POSIX")) else { return nil }
let meters = NSDecimalNumber(decimal: value * 1_000).intValue
guard Limits.queueDistance.contains(meters) else { return nil }
return meters
}
func openSettingChangeLog() {
onNavigateLog?()
}
/// 打开打卡点选择器前加载全部打卡点。
func loadPunchSpotOptions(api: ScenicQueueAPIProtocol) async {
guard appStore.session.currentScenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
do {
let response = try await api.scenicSpotListAll(scenicId: String(appStore.session.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 reportOutOfRangeNumericPending(fieldKey: String, pending: Bool) {
if pending {
blockingInvalidNumericFieldKeys.insert(fieldKey)
highlightedInvalidFieldKeys.insert(fieldKey)
} else {
blockingInvalidNumericFieldKeys.remove(fieldKey)
highlightedInvalidFieldKeys.remove(fieldKey)
}
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.scenicQueue.useOfflineTTS = false
notifyStateChange()
return
}
guard ttsManager.supportsOfflineTTS else {
appStore.scenicQueue.useOfflineTTS = false
onShowMessage?("离线语音资源未安装")
notifyStateChange()
return
}
appStore.scenicQueue.useOfflineTTS = 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 startPresetVoiceLoop(_ phrase: String) {
let text = String(phrase.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Limits.remarkMaxLen))
guard !text.isEmpty else { return }
Task {
if ttsManager.customTextLooping {
ttsManager.stopCustomTextLoop()
}
let ok = await ttsManager.startCustomTextLoop(text)
ttsLoopAnchorText = ok ? text : nil
if !ok { onShowMessage?("播报失败,请检查网络与语音服务") }
notifyStateChange()
}
}
/// 播放蓝牙测试音。
func playBluetoothTestSound() {
Task {
let ok = await ttsManager.speakPlainTextOnce("蓝牙音响测试音,请确认外放音量是否合适。")
if !ok {
onShowMessage?("测试音播放失败,请检查网络与语音服务")
}
}
}
/// 保存当前文案到本地预设。
func saveCurrentTextAsPresetVoiceLocal() {
guard appStore.session.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.scenicQueue.savePresetVoices(presetVoices)
onShowMessage?("已保存到本地")
notifyStateChange()
}
func deletePresetVoice(at index: Int) {
guard presetVoices.indices.contains(index) else { return }
let removed = presetVoices.remove(at: index)
appStore.scenicQueue.savePresetVoices(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 = blockingInvalidNumericFieldKeys
guard let ids = currentScenicAndSpotIdsForSelected() else {
highlightedInvalidFieldKeys = ["punchSpot"]
settingsTab = .basic
onShowMessage?("请选择打卡点")
notifyStateChange()
return
}
guard blockingInvalidNumericFieldKeys.isEmpty else {
settingsTab = tabForValidationKeys(blockingInvalidNumericFieldKeys)
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.scenicQueue.punchSpotId = String(ids.scenicSpotId)
appStore.scenicQueue.punchSpotName = selectedPunchSpotLabel
appStore.scenicQueue.remark = remark
appStore.scenicQueue.savePresetVoices(presetVoices)
appStore.scenicQueue.saveSettingsSnapshot(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.session.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.session.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
}
}