Files
suixinkan_uikit/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift
2026-07-07 15:32:25 +08:00

497 lines
22 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> = []
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 "请选择打卡点"
}
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 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("拍摄时长:分钟须在 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.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
}
}