Files
suixinkan_uikit/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift
汉秋 005349f8e6 模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:01:08 +08:00

683 lines
27 KiB
Swift
Raw Permalink 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.

//
// ScenicQueueViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `ScenicQueueViewModel`
final class ScenicQueueViewModel {
private enum Constants {
static let pageSize = 10
static let typeCurrent = 1
static let typeFinished = 2
static let prepareCallLoopGapNanos: UInt64 = 800_000_000
}
private(set) var summary: ScenicQueueSummary = .empty
private(set) var currentQueue: [ScenicQueueTicket] = []
private(set) var skippedQueue: [ScenicQueueSkippedTicket] = []
private(set) var currentRefreshing = false
private(set) var skippedRefreshing = false
private(set) var currentLoadingMore = false
private(set) var skippedLoadingMore = false
private(set) var currentCanLoadMore = false
private(set) var skippedCanLoadMore = false
private(set) var queueGatePassed = false
private(set) var selectedPunchSpotLine = "--"
private(set) var shootingTimedQueueNo: String?
private(set) var shootingRemainSeconds = 0
private(set) var showStartShootingButton = true
private(set) var quickCallButtonEnabled = false
private(set) var prepareCallButtonEnabled = false
private(set) var prepareCallPlaying = false
private(set) var pendingSkip: ScenicQueuePendingSkip?
private(set) var pendingCall: ScenicQueuePendingCall?
private(set) var pendingFinish: ScenicQueuePendingFinish?
private(set) var pendingMark: ScenicQueuePendingMark?
private(set) var pendingRequeue: ScenicQueuePendingRequeue?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onRequireSettings: (() -> Void)?
var onNavigateSettings: (() -> Void)?
var onNavigateBack: (() -> Void)?
private let appStore: AppStore
private let ttsManager: ScenicQueueTTSManaging
private let socketClient: ScenicQueueSocketClient
private var currentListTotal = 0
private var skippedListTotal = 0
private var currentLastLoadedPage = 0
private var skippedLastLoadedPage = 0
private var pendingRemoteCalledRecordIds: Set<Int64> = []
private var handledRemoteCallEventIds: Set<String> = []
private var shootingTask: Task<Void, Never>?
private var prepareCallTask: Task<Void, Never>?
init(
appStore: AppStore = .shared,
ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared,
socketClient: ScenicQueueSocketClient = ScenicQueueSocketClient()
) {
self.appStore = appStore
self.ttsManager = ttsManager
self.socketClient = socketClient
self.socketClient.onMessage = { [weak self] message in
self?.handleSocketMessage(message)
}
refreshSelectedPunchSpotLine()
refreshQueueGateLocally()
}
deinit {
stopQueueStatsPolling()
shootingTask?.cancel()
prepareCallTask?.cancel()
ttsManager.stopQueueCallLoop()
ttsManager.stopCustomTextLoop()
}
/// gate
func refreshQueueGate(api: ScenicQueueAPIProtocol) async {
refreshSelectedPunchSpotLine()
refreshQueueGateLocally()
if queueGatePassed {
await syncQueueSettingFromServerIfMatchesLocal(api: api)
await refreshQueueStats(api: api)
await refreshCurrentQueue(api: api, reset: true)
await refreshSkippedQueue(api: api, reset: true)
await ttsManager.prepareQueueTTSEngine(api: api)
} else {
resetQueues()
onRequireSettings?()
}
}
/// WebSocket
func startQueueStatsPolling(api: ScenicQueueAPIProtocol) {
guard queueGatePassed,
let scenicId = Int64("\(appStore.session.currentScenicId)"),
let scenicSpotId = Int64(appStore.scenicQueue.punchSpotId),
scenicId > 0,
scenicSpotId > 0 else { return }
Task {
do {
let token = try await api.socketToken().socketToken
socketClient.connect(socketToken: token, scenicId: scenicId, scenicSpotId: scenicSpotId)
} catch {
onShowMessage?(error.localizedDescription)
}
}
}
/// WebSocket
func stopQueueStatsPolling() {
socketClient.disconnect()
}
///
func openSettings() {
if shootingTimedQueueNo != nil {
onShowMessage?("请先完成上一位拍摄,才可以进入设置页面。")
return
}
onNavigateSettings?()
}
/// 退
func exitQueueModule() {
onNavigateBack?()
}
///
func refreshCurrentQueue(api: ScenicQueueAPIProtocol, reset: Bool = true) async {
await loadHomeList(api: api, type: Constants.typeCurrent, page: 1, append: false, isRefresh: reset)
}
///
func loadMoreCurrentQueue(api: ScenicQueueAPIProtocol) async {
guard currentCanLoadMore, !currentRefreshing, !currentLoadingMore else { return }
await loadHomeList(api: api, type: Constants.typeCurrent, page: currentLastLoadedPage + 1, append: true, isRefresh: false)
}
///
func refreshSkippedQueue(api: ScenicQueueAPIProtocol, reset: Bool = true) async {
await loadHomeList(api: api, type: Constants.typeFinished, page: 1, append: false, isRefresh: reset)
}
///
func loadMoreSkippedQueue(api: ScenicQueueAPIProtocol) async {
guard skippedCanLoadMore, !skippedRefreshing, !skippedLoadingMore else { return }
await loadHomeList(api: api, type: Constants.typeFinished, page: skippedLastLoadedPage + 1, append: true, isRefresh: false)
}
///
func refreshQueueStats(api: ScenicQueueAPIProtocol) async {
guard let ids = currentScenicAndSpotIds() else { return }
do {
let data = try await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
let avg = data.avgWaitMin.isFinite ? max(0, Int(data.avgWaitMin.rounded())) : 0
summary = ScenicQueueSummary(peopleInQueue: max(0, data.queueCount), avgWaitMinutes: avg)
notifyStateChange()
} catch {
// Android ignores stats errors silently.
}
}
///
func onTicketMarkClick(recordId: Int64, queueNo: String, uid: Int64) {
guard recordId > 0, uid > 0, !queueNo.isEmpty,
let ticket = currentQueue.first(where: { $0.recordId == recordId }) else { return }
pendingMark = ScenicQueuePendingMark(
uid: uid,
queueNo: queueNo,
phoneMasked: ticket.phoneMasked,
markAsFreelancePhotog: ticket.markAsFreelancePhotog
)
notifyStateChange()
}
func dismissMarkDialog() {
pendingMark = nil
notifyStateChange()
}
///
func confirmUserMark(api: ScenicQueueAPIProtocol, markAsFreelancePhotog: Bool, queueBanDays: Int) async {
guard let pendingMark else { return }
guard appStore.session.currentScenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
let request = ScenicQueueUserMarkRequest(
uid: pendingMark.uid,
scenicId: Int64(appStore.session.currentScenicId),
markAsFreelancePhotog: markAsFreelancePhotog ? 1 : 0,
queueBanDays: markAsFreelancePhotog ? min(max(queueBanDays, 0), 999) : nil,
operatorId: Int(appStore.session.userId)
)
do {
try await api.userMark(request)
dismissMarkDialog()
await refreshCurrentQueue(api: api, reset: true)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func requestSkipTicket(recordId: Int64, queueNo: String) {
guard recordId > 0, !queueNo.isEmpty else { return }
pendingSkip = ScenicQueuePendingSkip(recordId: recordId, queueNo: queueNo)
notifyStateChange()
}
func dismissSkipDialog() {
pendingSkip = nil
notifyStateChange()
}
///
func confirmSkipTicket(api: ScenicQueueAPIProtocol) async {
guard let pending = pendingSkip else { return }
dismissSkipDialog()
ttsManager.stopQueueCallLoop()
do {
_ = try await api.pass(recordId: pending.recordId)
clearShootingSession(for: pending.queueNo)
removeTicketFromCurrentList(recordId: pending.recordId)
await refreshQueueStats(api: api)
await refreshSkippedQueue(api: api, reset: true)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func requestFinishConfirm(recordId: Int64, queueNo: String) {
guard recordId > 0, !queueNo.isEmpty else { return }
if let active = shootingTimedQueueNo, active != queueNo {
onShowMessage?("请先完成上一位拍摄。")
return
}
pendingFinish = ScenicQueuePendingFinish(recordId: recordId, queueNo: queueNo)
notifyStateChange()
}
func dismissFinishDialog() {
pendingFinish = nil
notifyStateChange()
}
///
func confirmFinishTicket(api: ScenicQueueAPIProtocol) async {
guard let pending = pendingFinish else { return }
dismissFinishDialog()
await finishTicket(api: api, pending: pending)
}
func requestRequeueConfirm(recordId: Int64, queueNo: String) {
guard recordId > 0, !queueNo.isEmpty else { return }
pendingRequeue = ScenicQueuePendingRequeue(recordId: recordId, queueNo: queueNo)
notifyStateChange()
}
func dismissRequeueDialog() {
pendingRequeue = nil
notifyStateChange()
}
///
func confirmRequeueTicket(api: ScenicQueueAPIProtocol) async {
guard let pending = pendingRequeue else { return }
dismissRequeueDialog()
guard let operatorId = Int(appStore.session.userId), operatorId > 0 else {
onShowMessage?("请先登录")
return
}
do {
try await api.requeueInsertBefore(recordId: pending.recordId, operatorId: operatorId)
await refreshQueueStats(api: api)
await refreshCurrentQueue(api: api, reset: true)
await refreshSkippedQueue(api: api, reset: true)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func requestCallConfirm(recordId: Int64, queueNo: String, isRecall: Bool = false) {
if shootingTimedQueueNo != nil {
onShowMessage?("请先完成上一位拍摄,再进行叫号。")
return
}
guard recordId > 0, !queueNo.isEmpty else { return }
pendingCall = ScenicQueuePendingCall(recordId: recordId, queueNo: queueNo, isRecall: isRecall)
notifyStateChange()
}
func dismissCallConfirmDialog() {
pendingCall = nil
notifyStateChange()
}
///
func confirmCallTicket(api: ScenicQueueAPIProtocol) async {
guard let pending = pendingCall else { return }
dismissCallConfirmDialog()
stopPrepareCallPlayback()
await callTicketDirectly(api: api, recordId: pending.recordId, queueNo: pending.queueNo)
}
///
func quickCallFirstTicket(api: ScenicQueueAPIProtocol) async {
if shootingTimedQueueNo != nil {
onShowMessage?("请先完成上一位拍摄,再进行叫号。")
return
}
guard let first = currentQueue.first else {
onShowMessage?("暂无可叫号的排队用户")
return
}
stopPrepareCallPlayback()
await callTicketDirectly(api: api, recordId: first.recordId, queueNo: first.queueNo)
}
///
func togglePrepareCallPlayback() {
if prepareCallPlaying {
stopPrepareCallPlayback()
return
}
let queueNos = prepareCallQueueNos()
guard !queueNos.isEmpty else {
onShowMessage?("暂无可播报的排队用户")
return
}
prepareCallTask?.cancel()
prepareCallPlaying = true
notifyStateChange()
prepareCallTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
let latest = self.prepareCallQueueNos()
if latest.isEmpty { break }
_ = await self.ttsManager.speakPlainTextOnce(self.prepareCallAnnouncementText(latest))
try? await Task.sleep(nanoseconds: Constants.prepareCallLoopGapNanos)
}
self.prepareCallPlaying = false
self.prepareCallTask = nil
self.notifyStateChange()
}
}
///
func startShooting(api: ScenicQueueAPIProtocol, recordId: Int64, queueNo: String) {
guard recordId > 0, !queueNo.isEmpty, shootingTimedQueueNo == nil else { return }
stopPrepareCallPlayback()
ttsManager.stopQueueCallLoop()
let timing = resolveShootingTiming()
guard timing.totalSeconds > 0 else {
onShowMessage?("请先在排队配置中设置拍摄时长")
return
}
onShowMessage?("开始计时拍摄")
shootingTimedQueueNo = queueNo
shootingRemainSeconds = timing.totalSeconds
notifyStateChange()
shootingTask?.cancel()
shootingTask = Task { [weak self] in
guard let self else { return }
await self.speakFollowingTickets(after: queueNo)
let start = Date()
while !Task.isCancelled {
let elapsed = Int(Date().timeIntervalSince(start).rounded(.down))
let remain = max(0, timing.totalSeconds - elapsed)
self.shootingRemainSeconds = remain
self.notifyStateChange()
if remain <= 0 { break }
if remain == timing.readableThresholdSeconds {
_ = await self.ttsManager.speakPlainTextOnce("当前剩余拍摄时间还剩\(remain)")
} else if remain < timing.readableThresholdSeconds && remain > 0 {
_ = await self.ttsManager.speakPlainTextOnce("\(remain)")
} else if timing.intervalSeconds > 0 && elapsed > 0 && elapsed % timing.intervalSeconds == 0 {
_ = await self.ttsManager.speakPlainTextOnce(self.shootingRemainAnnouncementText(queueNo: queueNo, remainSeconds: remain))
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
self.ttsManager.interruptShootingAnnouncement()
self.shootingTimedQueueNo = nil
self.shootingRemainSeconds = 0
self.shootingTask = nil
self.notifyStateChange()
if !Task.isCancelled {
await self.finishTicket(api: api, pending: ScenicQueuePendingFinish(recordId: recordId, queueNo: queueNo))
}
}
}
private func refreshSelectedPunchSpotLine() {
selectedPunchSpotLine = appStore.scenicQueue.hasPunchSpotSaved()
? appStore.scenicQueue.punchSpotName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "--"
: "--"
refreshShootingCallConfig()
}
private func refreshQueueGateLocally() {
queueGatePassed = appStore.scenicQueue.hasPunchSpotSaved()
}
private func refreshShootingCallConfig() {
let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()
showStartShootingButton = snapshot.showStartShootingButton
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
}
private func syncQueueSettingFromServerIfMatchesLocal(api: ScenicQueueAPIProtocol) async {
guard let ids = currentScenicAndSpotIds() else { return }
do {
let data = try await api.setting(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
guard data.exists, let setting = data.setting,
setting.scenicId == nil || setting.scenicId == ids.scenicId,
setting.scenicSpotId == nil || setting.scenicSpotId == ids.scenicSpotId else { return }
let local = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()
appStore.scenicQueue.saveSettingsSnapshot(setting.toSnapshot(fallback: local))
appStore.scenicQueue.remark = setting.remark ?? ""
if let voices = setting.voiceBroadcasts {
appStore.scenicQueue.savePresetVoices(
voices
.sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) }
.compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty }
)
}
if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty {
appStore.scenicQueue.punchSpotName = name
selectedPunchSpotLine = name
}
refreshShootingCallConfig()
notifyStateChange()
} catch {
// Android keeps local snapshot if sync fails.
}
}
private func loadHomeList(api: ScenicQueueAPIProtocol, type: Int, page: Int, append: Bool, isRefresh: Bool) async {
guard let ids = currentScenicAndSpotIds() else { return }
let isCurrent = type == Constants.typeCurrent
if isRefresh {
if isCurrent {
guard !currentRefreshing else { return }
currentRefreshing = true
} else {
guard !skippedRefreshing else { return }
skippedRefreshing = true
}
} else {
if isCurrent {
guard !currentLoadingMore else { return }
currentLoadingMore = true
} else {
guard !skippedLoadingMore else { return }
skippedLoadingMore = true
}
}
notifyStateChange()
defer {
if isRefresh {
if isCurrent { currentRefreshing = false } else { skippedRefreshing = false }
} else {
if isCurrent { currentLoadingMore = false } else { skippedLoadingMore = false }
}
notifyStateChange()
}
do {
let data = try await api.home(
scenicId: ids.scenicId,
scenicSpotId: ids.scenicSpotId,
type: type,
page: page,
pageSize: Constants.pageSize
)
guard let block = data.list else { return }
if isCurrent {
let mapped = block.list.map { $0.toCurrentTicket() }
currentQueue = append ? appendUnique(existing: currentQueue, incoming: mapped, key: \.recordId) : mapped
currentListTotal = max(0, block.total)
currentLastLoadedPage = max(1, block.page)
currentCanLoadMore = currentQueue.count < currentListTotal && !block.list.isEmpty
consumePendingRemoteCalledTickets()
} else {
let mapped = block.list.map { $0.toSkippedTicket() }
skippedQueue = append ? appendUnique(existing: skippedQueue, incoming: mapped, key: \.recordId) : mapped
skippedListTotal = max(0, block.total)
skippedLastLoadedPage = max(1, block.page)
skippedCanLoadMore = skippedQueue.count < skippedListTotal && !block.list.isEmpty
}
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func handleSocketMessage(_ message: ScenicQueueSocketMessage) {
guard let params = message.data?.params,
let changedSpotId = params.scenicSpotId,
let currentSpotId = Int64(appStore.scenicQueue.punchSpotId),
changedSpotId == currentSpotId else { return }
switch message.data?.action {
case ScenicQueueSocketAction.queueUpdated.rawValue:
NotificationCenter.default.post(name: NotificationName.scenicQueueDidUpdate, object: nil)
case ScenicQueueSocketAction.ticketCalled.rawValue:
handleRemoteTicketCalled(params)
NotificationCenter.default.post(name: NotificationName.scenicQueueDidUpdate, object: nil)
default:
break
}
}
private func handleRemoteTicketCalled(_ params: ScenicQueueSocketParams) {
guard let recordId = params.recordId, recordId > 0 else { return }
if let myUid = Int64(appStore.session.userId), let operatorUid = params.operatorUid, myUid == operatorUid {
return
}
let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !eventId.isEmpty, handledRemoteCallEventIds.contains(eventId) {
return
}
if !eventId.isEmpty {
handledRemoteCallEventIds.insert(eventId)
}
pendingRemoteCalledRecordIds.insert(recordId)
}
private func consumePendingRemoteCalledTickets() {
guard let called = currentQueue.first(where: { pendingRemoteCalledRecordIds.contains($0.recordId) && $0.status == .called }) else { return }
pendingRemoteCalledRecordIds.remove(called.recordId)
stopPrepareCallPlayback()
ttsManager.startQueueCallLoop(queueNo: called.queueNo)
}
private func callTicketDirectly(api: ScenicQueueAPIProtocol, recordId: Int64, queueNo: String) async {
do {
let data = try await api.call(recordId: recordId)
applyCallSuccessLocally(recordId: recordId, queueNo: queueNo, statusText: data.statusText)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func applyCallSuccessLocally(recordId: Int64, queueNo: String, statusText: String) {
let shouldShowStartButton = currentShowStartShootingButton()
if shouldShowStartButton {
ttsManager.startQueueCallLoop(queueNo: queueNo)
}
currentQueue = currentQueue.map { ticket in
ticket.recordId == recordId || ticket.queueNo == queueNo
? ticket.markedCalled(statusText: statusText)
: ticket
}
notifyStateChange()
}
private func finishTicket(api: ScenicQueueAPIProtocol, pending: ScenicQueuePendingFinish) async {
do {
let data = try await api.finish(recordId: pending.recordId)
ttsManager.stopQueueCallLoop()
clearShootingSession(for: pending.queueNo)
removeTicketFromCurrentList(recordId: pending.recordId)
if !data.statusText.isEmpty {
onShowMessage?(data.statusText)
}
await refreshQueueStats(api: api)
await refreshSkippedQueue(api: api, reset: true)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func removeTicketFromCurrentList(recordId: Int64) {
currentQueue.removeAll { $0.recordId == recordId }
currentListTotal = max(0, currentListTotal - 1)
currentCanLoadMore = currentListTotal > 0 && currentQueue.count < currentListTotal
notifyStateChange()
}
private func clearShootingSession(for queueNo: String) {
guard shootingTimedQueueNo == queueNo else { return }
shootingTask?.cancel()
shootingTask = nil
ttsManager.interruptShootingAnnouncement()
shootingTimedQueueNo = nil
shootingRemainSeconds = 0
notifyStateChange()
}
private func stopPrepareCallPlayback() {
prepareCallTask?.cancel()
prepareCallTask = nil
ttsManager.interruptShootingAnnouncement()
prepareCallPlaying = false
notifyStateChange()
}
private func prepareCallQueueNos() -> [String] {
let active = shootingTimedQueueNo?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return currentQueue
.map { $0.queueNo.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && (active.isEmpty || $0 != active) }
.prefix(3)
.map { $0 }
}
private func prepareCallAnnouncementText(_ queueNos: [String]) -> String {
let joined = queueNos.map(AliyunNLSTTSManager.formatQueueNoForTTS).joined(separator: "")
return "\(joined),请提前做好拍摄准备。"
}
private func resolveShootingTiming() -> (totalSeconds: Int, intervalSeconds: Int, readableThresholdSeconds: Int) {
let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()
let total = max(0, snapshot.shootMinute * 60 + snapshot.shootSecond)
let interval = (40...60).contains(snapshot.broadcastIntervalSec) ? snapshot.broadcastIntervalSec : 50
let readable = (10...30).contains(snapshot.countdownThresholdSec) ? snapshot.countdownThresholdSec : 15
return (total, interval, readable)
}
private func currentShowStartShootingButton() -> Bool {
refreshShootingCallConfig()
return showStartShootingButton
}
private func speakFollowingTickets(after queueNo: String) async {
let count = (appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()).autoCallAheadCount.clamped(to: 0...5)
guard count > 0, let index = currentQueue.firstIndex(where: { $0.queueNo == queueNo }) else { return }
let nextNos = currentQueue.dropFirst(index + 1).prefix(count).map(\.queueNo).filter { !$0.isEmpty }
guard !nextNos.isEmpty else { return }
_ = await ttsManager.speakPlainTextOnce("\(nextNos.map(AliyunNLSTTSManager.formatQueueNoForTTS).joined(separator: ""))提前做好拍摄准备。")
}
private func shootingRemainAnnouncementText(queueNo: String, remainSeconds: Int) -> String {
"\(AliyunNLSTTSManager.formatQueueNoForTTS(queueNo)),当前剩余拍摄时间还剩\(remainSeconds)"
}
private func currentScenicAndSpotIds() -> (scenicId: Int64, scenicSpotId: Int64)? {
let scenicId = Int64(appStore.session.currentScenicId)
guard scenicId > 0,
let scenicSpotId = Int64(appStore.scenicQueue.punchSpotId),
scenicSpotId > 0 else { return nil }
return (scenicId, scenicSpotId)
}
private func resetQueues() {
summary = .empty
currentQueue = []
skippedQueue = []
currentListTotal = 0
skippedListTotal = 0
currentLastLoadedPage = 0
skippedLastLoadedPage = 0
currentCanLoadMore = false
skippedCanLoadMore = false
notifyStateChange()
}
private func appendUnique<Item, Key: Hashable>(existing: [Item], incoming: [Item], key: KeyPath<Item, Key>) -> [Item] {
var seen = Set(existing.map { $0[keyPath: key] })
return existing + incoming.filter { seen.insert($0[keyPath: key]).inserted }
}
private func notifyStateChange() {
onStateChange?()
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}
private extension Int {
func clamped(to range: ClosedRange<Int>) -> Int {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}