Advance UIKit rewrite with AMap integration and core UI modules.

Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 15:16:12 +08:00
parent 9edf993432
commit d99a5b1bf8
124 changed files with 5195 additions and 1536 deletions

View File

@ -209,6 +209,7 @@ final class QueueManagementViewModel {
return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0)
}
/// SocketMessage
private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
guard message.isScenicQueueEvent,
let params = message.data?.params,
@ -227,6 +228,7 @@ final class QueueManagementViewModel {
}
}
/// RemoteTicketCalled
private func handleRemoteTicketCalled(params: ScenicQueueSocketParams, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
guard let recordId = params.recordId, recordId > 0 else { return }
if let operatorUid = params.operatorUid,
@ -251,6 +253,7 @@ final class QueueManagementViewModel {
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
}
/// QueueingTickets
private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] {
let home = try await api.scenicQueueHome(
scenicId: scenicId,
@ -262,6 +265,7 @@ final class QueueManagementViewModel {
return home.list?.list ?? []
}
/// consumeCalledTickets
private func consumePendingRemoteCalledTickets(from tickets: [QueueItem]) {
guard let ticket = tickets.first(where: {
pendingRemoteCalledRecordIds.contains($0.id) && ($0.isCalled == 1 || $0.status == 1 || $0.statusText.contains("已叫号"))
@ -272,6 +276,7 @@ final class QueueManagementViewModel {
remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode)
}
/// Page
private func loadPage(api: any ScenicQueueServing, scenicId: Int, userId: String?, page targetPage: Int) async throws {
guard let scenicSpotId = selectedSpotId else { return }
let home = try await api.scenicQueueHome(
@ -300,6 +305,7 @@ final class QueueManagementViewModel {
refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId)
}
/// markCalled
private func markQueueCalled(id: Int64, statusText: String) {
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
items = items.map { item in
@ -330,6 +336,7 @@ final class QueueManagementViewModel {
}
}
///
private func reset() {
stopRealtime()
loading = false
@ -343,6 +350,7 @@ final class QueueManagementViewModel {
clearQueueData()
}
///
private func clearQueueData() {
loadingMore = false
items = []
@ -354,6 +362,7 @@ final class QueueManagementViewModel {
lastSyncTimeText = "--"
}
/// ShootingCallConfig
private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) {
let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot()
showStartShootingButton = snapshot.showStartShootingButton
@ -362,6 +371,7 @@ final class QueueManagementViewModel {
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
}
/// QueueSettingFromServerIfMatchesLocal
private func syncQueueSettingFromServerIfMatchesLocal(api: any ScenicQueueServing, userId: String?, scenicId: Int, spotId: Int) async {
guard let data = try? await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: spotId),
data.exists,
@ -648,6 +658,7 @@ final class ScenicQueueSettingsViewModel {
presetVoices.remove(at: index)
}
/// SettingsState
private func resetSettingsState() {
loading = false
scenicSpots = []
@ -678,6 +689,7 @@ final class ScenicQueueSettingsViewModel {
qrcodeURL = ""
}
/// apply
private func apply(_ setting: ScenicQueueSettingItem?) {
guard let setting else { return }
if let spotId = setting.scenicSpotId { selectedSpotId = spotId }
@ -707,6 +719,7 @@ final class ScenicQueueSettingsViewModel {
if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) }
}
/// apply
private func apply(_ snapshot: ScenicQueueSettingsSnapshot) {
photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))"
photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))"
@ -730,6 +743,7 @@ final class ScenicQueueSettingsViewModel {
businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0))
}
/// dMaxQueueRangeForDisplay
private func normalizedMaxQueueRangeForDisplay() throws -> String {
let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm)
guard !normalized.isEmpty,
@ -745,12 +759,14 @@ final class ScenicQueueSettingsViewModel {
return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue)
}
/// queueDistanceMeter
private func queueDistanceMeterForSubmit() throws -> Int {
_ = try normalizedMaxQueueRangeForDisplay()
let value = Decimal(string: maxQueueRangeKm, locale: Locale(identifier: "en_US_POSIX")) ?? 0
return NSDecimalNumber(decimal: value * Decimal(1000)).intValue
}
/// QRCodeImage
private func loadQRCodeImage(from text: String) async throws -> UIImage {
if let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased()) {
let (data, _) = try await URLSession.shared.data(from: url)
@ -760,6 +776,7 @@ final class ScenicQueueSettingsViewModel {
throw APIError.networkFailed("二维码图片生成失败")
}
/// generateQRCode
private static func generateQRCode(from string: String) -> UIImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
@ -770,12 +787,14 @@ final class ScenicQueueSettingsViewModel {
return UIImage(cgImage: cgImage)
}
/// saveTiming
private func saveTimingSnapshot(min: Int, sec: Int, interval: Int, countdown: Int) {
UserDefaults.standard.set(max(min * 60 + sec, 0), forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey)
UserDefaults.standard.set(interval, forKey: ScenicQueueLocalSettings.broadcastIntervalSecondsKey)
UserDefaults.standard.set(countdown, forKey: ScenicQueueLocalSettings.countdownThresholdSecondsKey)
}
/// saveShootingCall
private func saveShootingCallSettings(autoCallAhead: Int) {
UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey)
UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey)
@ -783,10 +802,12 @@ final class ScenicQueueSettingsViewModel {
UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey)
}
/// saveLocally
private func saveCustomTextLocally() {
ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId)
}
/// voiceBroadcasts
private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] {
Array(presetVoices
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
@ -796,6 +817,7 @@ final class ScenicQueueSettingsViewModel {
.map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) })
}
/// Snapshot
private func makeSnapshot(
min: Int? = nil,
sec: Int? = nil,
@ -832,18 +854,22 @@ final class ScenicQueueSettingsViewModel {
)
}
/// coerced
static func coercedBroadcastInterval(_ raw: Int) -> Int {
(40...60).contains(raw) ? raw : 50
}
/// coerced
static func coercedCountdownThreshold(_ raw: Int) -> Int {
(10...30).contains(raw) ? raw : 15
}
/// business
static func businessTime(hour: Int, minute: Int) -> Date {
Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date()
}
/// BusinessTime
static func parseBusinessTime(_ raw: String, fallback: Date) -> Date {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return fallback }
@ -856,6 +882,7 @@ final class ScenicQueueSettingsViewModel {
return fallback
}
/// business
static func businessTimePayload(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
@ -863,6 +890,7 @@ final class ScenicQueueSettingsViewModel {
return formatter.string(from: date)
}
/// queueDistanceDisplay
private static func queueDistanceDisplay(meters: Int) -> String {
let clamped = min(max(meters, 0), 9_999_990)
return String(format: "%.2f", Double(clamped) / 1000)
@ -875,6 +903,7 @@ private extension String {
return text.isEmpty ? nil : text
}
/// non
func nonEmptyOrDefault(_ fallback: String) -> String {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? fallback : text