Files
suixinkan_uikit/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift
汉秋 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("拍摄时长:分钟须在 030且按 1 分钟递增")
keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue)
}
if !Limits.shootSecond.contains(shootSecond) {
messages.append("拍摄时长:秒须在 059")
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("播报间隔时间须在 4060 秒")
keys.insert(ScenicQueueNumericFieldKey.broadcastIntervalSec.rawValue)
}
if !Limits.readableThreshold.contains(countdownThresholdSec) {
messages.append("进入读秒倒计时阈值须在 1030 秒")
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
}
}