diff --git a/suixinkan/Assets.xcassets/ScenicQueue/Contents.json b/suixinkan/Assets.xcassets/ScenicQueue/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicQueue/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/Contents.json new file mode 100644 index 0000000..f8834a3 --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "queue_location.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/queue_location.svg b/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/queue_location.svg new file mode 100644 index 0000000..a1dc49f --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/queue_location.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift b/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift index 36caf56..48f47be 100644 --- a/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift +++ b/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift @@ -28,6 +28,20 @@ struct ScenicQueueHomeData: Decodable, Equatable { /// 排队 home 接口 stats 节点。 struct ScenicQueueHomeStats: Decodable, Equatable { let type: Int + + /// 创建 stats 节点;Android DTO 在服务端未返回 type 时默认使用 0。 + init(type: Int = 0) { + self.type = type + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0 + } + + private enum CodingKeys: String, CodingKey { + case type + } } /// 排队 home 接口分页列表节点。 @@ -426,6 +440,43 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Hashable { } } +extension ScenicQueueSettingChangeLogItem { + /// Android 日志卡片的单行元信息:时间、操作人和手机号尾号。 + var metadataLine: String { + let time: String + if let createdAt, !createdAt.isEmpty { + let input = DateFormatter() + input.locale = Locale(identifier: "en_US_POSIX") + input.timeZone = TimeZone(identifier: "Asia/Shanghai") + let formats = ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ssXXXXX", "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"] + let parsedDate = formats.lazy.compactMap { format -> Date? in + input.dateFormat = format + return input.date(from: createdAt) + }.first + if let date = parsedDate { + let output = DateFormatter() + output.locale = Locale(identifier: "en_US_POSIX") + output.timeZone = TimeZone(identifier: "Asia/Shanghai") + output.dateFormat = "yyyy-MM-dd HH:mm" + time = output.string(from: date) + } else { + time = String(createdAt.replacingOccurrences(of: "T", with: " ").prefix(16)) + } + } else { + time = "—" + } + let name = operatorName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "未知" : operatorName.trimmingCharacters(in: .whitespacesAndNewlines) + let tail = operatorPhoneTail.trimmingCharacters(in: .whitespacesAndNewlines) + return tail.isEmpty ? "\(time) \(name)" : "\(time) \(name) 尾号\(tail)" + } + + /// 日志卡片主摘要,优先使用 summary,空时回退 displayText。 + var cardSummary: String { + let value = summary.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? displayText : value + } +} + /// 打卡点列表响应。 struct ScenicSpotListResponse: Decodable, Equatable { let list: [ScenicSpotItem] diff --git a/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift b/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift index 5d70f76..38091d3 100644 --- a/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift +++ b/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift @@ -6,11 +6,56 @@ import Foundation /// 当前排队列表中的叫号状态。 -enum ScenicQueueCallStatus: Equatable { +enum ScenicQueueCallStatus: Hashable { case waiting case called } +/// 当前排队卡片在指定拍摄会话与设置下的操作展示状态。 +struct ScenicQueueTicketActionState: Equatable { + let skipEnabled: Bool + let primaryVisible: Bool + let primaryEnabled: Bool + let primaryTitle: String + let recallVisible: Bool + let completeVisible: Bool + + /// 按 Android 排队卡片规则生成操作状态。 + static func make( + ticket: ScenicQueueTicket, + shootingQueueNo: String?, + remainSeconds: Int, + showStartShootingButton: Bool + ) -> ScenicQueueTicketActionState { + let isShooting = shootingQueueNo == ticket.queueNo + if isShooting { + return ScenicQueueTicketActionState( + skipEnabled: false, + primaryVisible: true, + primaryEnabled: false, + primaryTitle: "剩余 \(max(0, remainSeconds)) 秒", + recallVisible: false, + completeVisible: ticket.showCompleteAction + ) + } + let primaryVisible = ticket.status == .waiting || showStartShootingButton + return ScenicQueueTicketActionState( + skipEnabled: true, + primaryVisible: primaryVisible, + primaryEnabled: true, + primaryTitle: ticket.status == .waiting ? "叫号" : "开始拍摄", + recallVisible: ticket.status == .called, + completeVisible: ticket.showCompleteAction + ) + } +} + +/// 排队首页 Diffable Data Source 的稳定列表项。 +enum ScenicQueueListItem: Hashable { + case current(ScenicQueueTicket) + case skipped(ScenicQueueSkippedTicket) +} + /// 当前排队列表展示实体。 struct ScenicQueueTicket: Hashable { let recordId: Int64 diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift index 601b91f..7d25b10 100644 --- a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift @@ -15,6 +15,7 @@ final class ScenicQueueSettingChangeLogViewModel { private(set) var isRefreshing = false private(set) var isLoadingMore = false private(set) var canLoadMore = false + private(set) var initialLoading = true var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? @@ -50,6 +51,7 @@ final class ScenicQueueSettingChangeLogViewModel { } notifyStateChange() defer { + initialLoading = false if append { isLoadingMore = false } else { isRefreshing = false } notifyStateChange() } diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift index e256059..bb8e6e6 100644 --- a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift @@ -47,6 +47,7 @@ final class ScenicQueueSettingsViewModel { private(set) var presetVoices: [String] private(set) var settingsTab: ScenicQueueSettingsTab = .basic private(set) var highlightedInvalidFieldKeys: Set = [] + private(set) var blockingInvalidNumericFieldKeys: Set = [] var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? @@ -56,7 +57,7 @@ final class ScenicQueueSettingsViewModel { private let appStore: AppStore private let ttsManager: ScenicQueueTTSManaging private var punchSpotSettingRequestGeneration = 0 - private var ttsLoopAnchorText: String? + private(set) var ttsLoopAnchorText: String? init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) { self.appStore = appStore @@ -108,6 +109,30 @@ final class ScenicQueueSettingsViewModel { 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?() } @@ -146,6 +171,18 @@ final class ScenicQueueSettingsViewModel { 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() } @@ -222,6 +259,21 @@ final class ScenicQueueSettingsViewModel { } } + /// 循环播放指定预设文案;若已有文案播放则先停止再切换。 + 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 { @@ -289,7 +341,7 @@ final class ScenicQueueSettingsViewModel { /// 校验并保存设置。 func save(api: ScenicQueueAPIProtocol) async { - highlightedInvalidFieldKeys = [] + highlightedInvalidFieldKeys = blockingInvalidNumericFieldKeys guard let ids = currentScenicAndSpotIdsForSelected() else { highlightedInvalidFieldKeys = ["punchSpot"] settingsTab = .basic @@ -297,6 +349,12 @@ final class ScenicQueueSettingsViewModel { notifyStateChange() return } + guard blockingInvalidNumericFieldKeys.isEmpty else { + settingsTab = tabForValidationKeys(blockingInvalidNumericFieldKeys) + onShowMessage?("请修正标红的配置项") + notifyStateChange() + return + } let validation = buildSaveValidation() guard validation.messages.isEmpty else { highlightedInvalidFieldKeys = validation.keys diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index 9c51833..b90a710 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -194,13 +194,11 @@ final class WildPhotographerReportSubmitViewModel { /// 限制举报说明最多 500 字。 func updateDescription(_ newValue: String) { description = String(newValue.prefix(500)) - notifyStateChange() } /// 更新联系方式。 func updateContact(_ value: String) { contact = String(value.prefix(80)) - notifyStateChange() } /// 添加本地现场图片。 diff --git a/suixinkan/Theme/Modules/ScenicQueueTokens.swift b/suixinkan/Theme/Modules/ScenicQueueTokens.swift index 89f6067..00e4dc2 100644 --- a/suixinkan/Theme/Modules/ScenicQueueTokens.swift +++ b/suixinkan/Theme/Modules/ScenicQueueTokens.swift @@ -31,6 +31,13 @@ enum ScenicQueueTokens { static let dialogDangerLine = UIColor(hex: 0xD32F2F) static let fieldBorderMuted = UIColor(hex: 0xE5E7EB) static let settingsMutedBlock = UIColor(hex: 0xF7F8FA) + static let dialogCancelBackground = UIColor(hex: 0xEAF1F8) + static let markDialogCancelBackground = UIColor(hex: 0xF0F7FF) + static let markDialogDivider = UIColor(hex: 0xF0F0F0) + static let markDialogStepperBackground = UIColor(hex: 0xF2F4F7) + static let logCardBackground = UIColor(hex: 0xF7F8FA) + static let timelineLine = UIColor(hex: 0xD8E8FF) + static let timelineDot = UIColor(hex: 0x1677FF) static let radiusContentSheetTop: CGFloat = 14 static let radiusCard: CGFloat = 12 @@ -43,6 +50,9 @@ enum ScenicQueueTokens { static let listItemGap: CGFloat = 10 static let listHorizontalInset: CGFloat = 12 static let bottomSaveButtonRadius: CGFloat = 12 + static let markDialogRadius: CGFloat = 22 + static let markDialogButtonRadius: CGFloat = 12 + static let dialogHorizontalInset: CGFloat = 22 static let bannerLabelFont = UIFont.systemFont(ofSize: 12) static let bannerValueFont = UIFont.systemFont(ofSize: 30, weight: .bold) diff --git a/suixinkan/UI/Common/UIButton+Configuration.swift b/suixinkan/UI/Common/UIButton+Configuration.swift index 847d96a..90f1969 100644 --- a/suixinkan/UI/Common/UIButton+Configuration.swift +++ b/suixinkan/UI/Common/UIButton+Configuration.swift @@ -14,4 +14,12 @@ extension UIButton { updatedConfiguration.contentInsets = contentInsets configuration = updatedConfiguration } + + /// 设置图片相对标题的位置和间距,并保留按钮已有的 configuration 内容。 + func setConfigurationImagePlacement(_ imagePlacement: NSDirectionalRectEdge, padding: CGFloat) { + var updatedConfiguration = configuration ?? .plain() + updatedConfiguration.imagePlacement = imagePlacement + updatedConfiguration.imagePadding = padding + configuration = updatedConfiguration + } } diff --git a/suixinkan/UI/Home/ScenicSelectionViewController.swift b/suixinkan/UI/Home/ScenicSelectionViewController.swift index 46f059c..2570c5a 100644 --- a/suixinkan/UI/Home/ScenicSelectionViewController.swift +++ b/suixinkan/UI/Home/ScenicSelectionViewController.swift @@ -20,9 +20,9 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo private let tableView = UITableView(frame: .zero, style: .plain) private let emptyLabel = UILabel() - init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) { + init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI? = nil) { self.viewModel = viewModel - self.homeAPI = homeAPI + self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift index bde0a1b..96a6f79 100644 --- a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift +++ b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift @@ -58,8 +58,7 @@ final class HomeWorkStatusCardView: UIView { let reminderIconConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) reminderButton.setImage(UIImage(systemName: "bell.fill", withConfiguration: reminderIconConfig), for: .normal) reminderButton.semanticContentAttribute = .forceLeftToRight - reminderButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 6) - reminderButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: -6) + reminderButton.setConfigurationImagePlacement(.leading, padding: 6) reminderButton.titleLabel?.adjustsFontSizeToFitWidth = true reminderButton.titleLabel?.minimumScaleFactor = 0.8 reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside) diff --git a/suixinkan/UI/Invite/InviteRecordViewController.swift b/suixinkan/UI/Invite/InviteRecordViewController.swift index 7dafcd6..3347506 100644 --- a/suixinkan/UI/Invite/InviteRecordViewController.swift +++ b/suixinkan/UI/Invite/InviteRecordViewController.swift @@ -38,11 +38,11 @@ final class InviteRecordViewController: BaseViewController { /// 初始化邀请记录页面。 init( - inviteAPI: InviteServing = NetworkServices.shared.inviteAPI, - walletAPI: WalletServing = NetworkServices.shared.walletAPI + inviteAPI: InviteServing? = nil, + walletAPI: WalletServing? = nil ) { - self.inviteAPI = inviteAPI - self.walletAPI = walletAPI + self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI + self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/Invite/PhotographerInviteViewController.swift b/suixinkan/UI/Invite/PhotographerInviteViewController.swift index 0339be4..596f0cb 100644 --- a/suixinkan/UI/Invite/PhotographerInviteViewController.swift +++ b/suixinkan/UI/Invite/PhotographerInviteViewController.swift @@ -19,8 +19,8 @@ final class PhotographerInviteViewController: BaseViewController { private let ruleStack = UIStackView() /// 初始化摄影师邀请页面。 - init(inviteAPI: InviteServing = NetworkServices.shared.inviteAPI) { - self.inviteAPI = inviteAPI + init(inviteAPI: InviteServing? = nil) { + self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI super.init(nibName: nil, bundle: nil) } @@ -234,7 +234,7 @@ final class PhotographerInviteViewController: BaseViewController { button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) button.setImage(UIImage(named: "ic_invite_record")?.withRenderingMode(.alwaysTemplate), for: .normal) button.tintColor = AppColor.primary - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -6, bottom: 0, right: 6) + button.setConfigurationImagePlacement(.leading, padding: 6) button.addTarget(self, action: #selector(inviteRecordTapped), for: .touchUpInside) container.addSubview(button) button.snp.makeConstraints { make in @@ -360,8 +360,7 @@ private final class InviteIconButton: UIButton { setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal) tintColor = .white titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) - imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3) - titleEdgeInsets = UIEdgeInsets(top: 0, left: 3, bottom: 0, right: -3) + setConfigurationImagePlacement(.leading, padding: 6) } @available(*, unavailable) diff --git a/suixinkan/UI/Live/LiveAlbumViewControllers.swift b/suixinkan/UI/Live/LiveAlbumViewControllers.swift index fea7cb8..b1a753a 100644 --- a/suixinkan/UI/Live/LiveAlbumViewControllers.swift +++ b/suixinkan/UI/Live/LiveAlbumViewControllers.swift @@ -207,7 +207,7 @@ final class LiveAlbumListViewController: BaseViewController, UITableViewDelegate button.titleLabel?.font = .systemFont(ofSize: 13) button.setImage(UIImage(systemName: "calendar"), for: .normal) button.tintColor = AppColor.textPrimary - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) + button.setConfigurationImagePlacement(.leading, padding: 4) setDateButtonTitle(button, text: title) } diff --git a/suixinkan/UI/Live/LiveUIComponents.swift b/suixinkan/UI/Live/LiveUIComponents.swift index ed5455c..ac65070 100644 --- a/suixinkan/UI/Live/LiveUIComponents.swift +++ b/suixinkan/UI/Live/LiveUIComponents.swift @@ -75,7 +75,7 @@ final class LiveActionButton: UIButton { layer.cornerRadius = 8 clipsToBounds = true tintColor = .white - imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) + setConfigurationImagePlacement(.leading, padding: 4) updateAppearance() } diff --git a/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift b/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift index 0bd346f..4e212cd 100644 --- a/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift +++ b/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift @@ -20,10 +20,10 @@ final class LocationReportHistoryViewController: BaseViewController, UITableView init( viewModel: LocationReportHistoryViewModel = LocationReportHistoryViewModel(), - homeAPI: HomeAPI = NetworkServices.shared.homeAPI + homeAPI: HomeAPI? = nil ) { self.viewModel = viewModel - self.homeAPI = homeAPI + self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/LocationReport/LocationReportViewController.swift b/suixinkan/UI/LocationReport/LocationReportViewController.swift index 488b7c8..ee3ee95 100644 --- a/suixinkan/UI/LocationReport/LocationReportViewController.swift +++ b/suixinkan/UI/LocationReport/LocationReportViewController.swift @@ -24,10 +24,10 @@ final class LocationReportViewController: BaseViewController { init( viewModel: LocationReportViewModel = LocationReportViewModel(), - homeAPI: HomeAPI = NetworkServices.shared.homeAPI + homeAPI: HomeAPI? = nil ) { self.viewModel = viewModel - self.homeAPI = homeAPI + self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift index b691f4a..05a1543 100644 --- a/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift @@ -30,10 +30,10 @@ final class MaterialDetailViewController: BaseViewController { /// 初始化素材详情页。 init( materialId: Int, - api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI + api: (any MaterialManagementServing)? = nil ) { viewModel = MaterialDetailViewModel(materialId: materialId) - self.api = api + self.api = api ?? NetworkServices.shared.materialManagementAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift index b52737c..4c72e74 100644 --- a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift @@ -49,12 +49,12 @@ final class MaterialFormViewController: BaseViewController { /// 初始化素材表单页。 init( mode: MaterialFormViewModel.Mode, - api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI, - uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService + api: (any MaterialManagementServing)? = nil, + uploader: (any MaterialOSSUploading)? = nil ) { viewModel = MaterialFormViewModel(mode: mode) - self.api = api - self.uploader = uploader + self.api = api ?? NetworkServices.shared.materialManagementAPI + self.uploader = uploader ?? NetworkServices.shared.ossUploadService super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift index a4b298e..9997adf 100644 --- a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift @@ -33,10 +33,10 @@ final class MaterialListViewController: BaseViewController { /// 初始化素材管理列表页。 init( viewModel: MaterialListViewModel = MaterialListViewModel(), - api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI + api: (any MaterialManagementServing)? = nil ) { self.viewModel = viewModel - self.api = api + self.api = api ?? NetworkServices.shared.materialManagementAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/MessageCenter/MessageCenterViewController.swift b/suixinkan/UI/MessageCenter/MessageCenterViewController.swift index f394c8a..6d5695c 100644 --- a/suixinkan/UI/MessageCenter/MessageCenterViewController.swift +++ b/suixinkan/UI/MessageCenter/MessageCenterViewController.swift @@ -25,10 +25,10 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate /// 初始化消息中心列表页。 init( viewModel: MessageCenterViewModel = MessageCenterViewModel(), - api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI + api: (any MessageCenterServing)? = nil ) { self.viewModel = viewModel - self.api = api + self.api = api ?? NetworkServices.shared.messageCenterAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/MessageCenter/MessageDetailViewController.swift b/suixinkan/UI/MessageCenter/MessageDetailViewController.swift index ddc3f73..8b066b2 100644 --- a/suixinkan/UI/MessageCenter/MessageDetailViewController.swift +++ b/suixinkan/UI/MessageCenter/MessageDetailViewController.swift @@ -24,9 +24,9 @@ final class MessageDetailViewController: BaseViewController { private let deleteButton = UIButton(type: .system) /// 初始化消息详情页。 - init(message: MessageItem, api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI) { + init(message: MessageItem, api: (any MessageCenterServing)? = nil) { viewModel = MessageDetailViewModel(message: message) - self.api = api + self.api = api ?? NetworkServices.shared.messageCenterAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/Orders/DepositOrderDetailViewController.swift b/suixinkan/UI/Orders/DepositOrderDetailViewController.swift index d0a0a44..3166c83 100644 --- a/suixinkan/UI/Orders/DepositOrderDetailViewController.swift +++ b/suixinkan/UI/Orders/DepositOrderDetailViewController.swift @@ -14,9 +14,9 @@ final class DepositOrderDetailViewController: BaseViewController, UITableViewDat private var detail: StoreOrderDetailData? private let tableView = UITableView(frame: .zero, style: .insetGrouped) - init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) { + init(orderNumber: String, orderAPI: OrderAPI? = nil) { self.orderNumber = orderNumber - self.orderAPI = orderAPI + self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift b/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift index 9dff993..961c0d7 100644 --- a/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift +++ b/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift @@ -14,9 +14,9 @@ final class HistoricalShootingInfoViewController: BaseViewController, UITableVie private var response: MultiTravelShootHistoryResponse? private let tableView = UITableView(frame: .zero, style: .insetGrouped) - init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) { + init(orderNumber: String, orderAPI: OrderAPI? = nil) { self.orderNumber = orderNumber - self.orderAPI = orderAPI + self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift index 19aeebd..26564a2 100644 --- a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift +++ b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift @@ -53,10 +53,10 @@ final class ProfileSpaceSettingsViewController: BaseViewController { init( viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(), - profileAPI: ProfileAPI = NetworkServices.shared.profileAPI + profileAPI: ProfileAPI? = nil ) { self.viewModel = viewModel - self.profileAPI = profileAPI + self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/SampleManagement/SampleDetailViewController.swift b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift index fe1c1ea..490a2ad 100644 --- a/suixinkan/UI/SampleManagement/SampleDetailViewController.swift +++ b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift @@ -26,10 +26,10 @@ final class SampleDetailViewController: SampleBaseViewController { /// 初始化样片详情页。 init( sampleId: Int, - api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI + api: (any SampleManagementServing)? = nil ) { viewModel = SampleDetailViewModel(sampleId: sampleId) - self.api = api + self.api = api ?? NetworkServices.shared.sampleManagementAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/SampleManagement/SampleListViewController.swift b/suixinkan/UI/SampleManagement/SampleListViewController.swift index c57ddd8..1cb207a 100644 --- a/suixinkan/UI/SampleManagement/SampleListViewController.swift +++ b/suixinkan/UI/SampleManagement/SampleListViewController.swift @@ -32,10 +32,10 @@ final class SampleListViewController: SampleBaseViewController { /// 初始化样片列表页。 init( viewModel: SampleListViewModel = SampleListViewModel(), - api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI + api: (any SampleManagementServing)? = nil ) { self.viewModel = viewModel - self.api = api + self.api = api ?? NetworkServices.shared.sampleManagementAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift index 9a472b1..c2b10e8 100644 --- a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift +++ b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift @@ -51,12 +51,12 @@ final class UploadSampleViewController: SampleBaseViewController { /// 初始化样片上传页。 init( viewModel: UploadSampleViewModel = UploadSampleViewModel(), - api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI, - uploader: any SampleOSSUploading = NetworkServices.shared.ossUploadService + api: (any SampleManagementServing)? = nil, + uploader: (any SampleOSSUploading)? = nil ) { self.viewModel = viewModel - self.api = api - self.uploader = uploader + self.api = api ?? NetworkServices.shared.sampleManagementAPI + self.uploader = uploader ?? NetworkServices.shared.ossUploadService super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift b/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift index a634518..675e20f 100644 --- a/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift +++ b/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift @@ -41,14 +41,14 @@ final class ScenicApplicationViewController: BaseViewController { init( viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(), - homeAPI: HomeAPI = NetworkServices.shared.homeAPI, - profileAPI: ProfileAPI = NetworkServices.shared.profileAPI, - ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService + homeAPI: HomeAPI? = nil, + profileAPI: ProfileAPI? = nil, + ossUploadService: OSSUploadService? = nil ) { self.viewModel = viewModel - self.homeAPI = homeAPI - self.profileAPI = profileAPI - self.ossUploadService = ossUploadService + self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI + self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI + self.ossUploadService = ossUploadService ?? NetworkServices.shared.ossUploadService super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift index 3c1feb4..34b1fd9 100644 --- a/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift +++ b/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift @@ -6,12 +6,15 @@ import SnapKit import UIKit -/// 排队设置配置日志页。 +/// 排队配置日志页,按 Android 时间轴样式展示变更历史。 final class ScenicQueueSettingChangeLogViewController: BaseViewController { private let viewModel = ScenicQueueSettingChangeLogViewModel() private let api: ScenicQueueAPIProtocol private let tableView = UITableView(frame: .zero, style: .plain) private let emptyLabel = UILabel() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let footerLabel = UILabel() + private var dataSource: UITableViewDiffableDataSource! @MainActor init(api: ScenicQueueAPIProtocol? = nil) { @@ -20,133 +23,142 @@ final class ScenicQueueSettingChangeLogViewController: BaseViewController { } @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.refresh(api: api) } } - override func setupNavigationBar() { - title = "配置日志" - } + override func setupNavigationBar() { title = "配置日志" } override func setupUI() { - view.backgroundColor = ScenicQueueTokens.pageBackground - tableView.backgroundColor = .clear + view.backgroundColor = .white + tableView.backgroundColor = .white tableView.separatorStyle = .none tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 120 + tableView.estimatedRowHeight = 132 tableView.delegate = self - tableView.dataSource = self - tableView.register(ScenicQueueLogCell.self, forCellReuseIdentifier: ScenicQueueLogCell.reuseIdentifier) + tableView.register(ScenicQueueLogTimelineCell.self, forCellReuseIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier) tableView.refreshControl = UIRefreshControl() tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in + let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogTimelineCell + cell.configure(item: item, showDot: self?.shouldShowDot(at: indexPath.row) ?? false) + return cell + } emptyLabel.text = "暂无配置变更记录" - emptyLabel.textColor = AppColor.textTertiary + emptyLabel.textColor = AppColor.textSecondary emptyLabel.font = ScenicQueueTokens.emptyStateFont emptyLabel.textAlignment = .center + loadingIndicator.color = ScenicQueueTokens.bannerBlue + footerLabel.textAlignment = .center + footerLabel.textColor = AppColor.textSecondary + footerLabel.font = .systemFont(ofSize: 14) view.addSubview(tableView) view.addSubview(emptyLabel) + view.addSubview(loadingIndicator) } override func setupConstraints() { - tableView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) - } - emptyLabel.snp.makeConstraints { make in - make.center.equalToSuperview() - } + tableView.snp.makeConstraints { $0.edges.equalTo(view.safeAreaLayoutGuide) } + emptyLabel.snp.makeConstraints { $0.center.equalToSuperview() } + loadingIndicator.snp.makeConstraints { $0.center.equalToSuperview() } } override func bindActions() { - viewModel.onStateChange = { [weak self] in - Task { @MainActor in self?.applyState() } - } - viewModel.onShowMessage = { [weak self] message in - Task { @MainActor in self?.showToast(message) } - } + viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } } + viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } } private func applyState() { tableView.refreshControl?.endRefreshing() - tableView.reloadData() - emptyLabel.isHidden = !viewModel.items.isEmpty + viewModel.initialLoading && viewModel.items.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() + emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(viewModel.items) + dataSource.apply(snapshot, animatingDifferences: true) + if viewModel.items.isEmpty { + tableView.tableFooterView = UIView() + } else { + footerLabel.text = viewModel.isLoadingMore ? "加载中…" : (viewModel.canLoadMore ? "" : "没有更多") + footerLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) + tableView.tableFooterView = footerLabel + } } - @objc private func refreshPulled() { - Task { await viewModel.refresh(api: api) } + private func shouldShowDot(at index: Int) -> Bool { + let estimatedRowHeight: CGFloat = 132 + let itemsPerThird = max(1, Int(((view.bounds.height / 3) / estimatedRowHeight).rounded())) + let stride = max(3, min(12, itemsPerThird)) + return index % stride == 0 } + + @objc private func refreshPulled() { Task { await viewModel.refresh(api: api) } } } -extension ScenicQueueSettingChangeLogViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - viewModel.items.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogCell - cell.configure(item: viewModel.items[indexPath.row]) - return cell - } - +extension ScenicQueueSettingChangeLogViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return } + guard !viewModel.items.isEmpty, + scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else { return } Task { await viewModel.loadMore(api: api) } } } -/// 配置日志 cell。 -final class ScenicQueueLogCell: UITableViewCell { - static let reuseIdentifier = "ScenicQueueLogCell" - +/// 配置日志的时间轴卡片单元格。 +final class ScenicQueueLogTimelineCell: UITableViewCell { + static let reuseIdentifier = "ScenicQueueLogTimelineCell" + private let rail = UIView() + private let dot = UIView() private let card = UIView() - private let titleLabel = UILabel() - private let detailLabel = UILabel() - private let timeLabel = UILabel() + private let metaLabel = UILabel() + private let summaryLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - setupUI() + selectionStyle = .none + backgroundColor = .white + rail.backgroundColor = ScenicQueueTokens.timelineLine + dot.backgroundColor = ScenicQueueTokens.timelineDot + dot.layer.cornerRadius = 4 + card.backgroundColor = ScenicQueueTokens.logCardBackground + card.layer.cornerRadius = 12 + metaLabel.font = .systemFont(ofSize: 12) + metaLabel.textColor = AppColor.textSecondary + metaLabel.numberOfLines = 3 + summaryLabel.font = .systemFont(ofSize: 15, weight: .semibold) + summaryLabel.textColor = AppColor.textPrimary + summaryLabel.numberOfLines = 0 + let stack = UIStackView(arrangedSubviews: [metaLabel, summaryLabel]) + stack.axis = .vertical + stack.spacing = 8 + contentView.addSubview(rail) + contentView.addSubview(dot) + contentView.addSubview(card) + card.addSubview(stack) + rail.snp.makeConstraints { make in + make.centerX.equalTo(contentView.snp.leading).offset(UIScreen.main.bounds.width * 0.10) + make.top.bottom.equalToSuperview() + make.width.equalTo(1.5) + } + dot.snp.makeConstraints { make in make.centerX.equalTo(rail); make.top.equalToSuperview().offset(18); make.size.equalTo(8) } + card.snp.makeConstraints { make in + make.leading.equalTo(rail.snp.trailing).offset(20) + make.trailing.equalToSuperview().offset(-16) + make.top.equalToSuperview().offset(6) + make.bottom.equalToSuperview().offset(-6) + } + stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(15) } } @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(item: ScenicQueueSettingChangeLogItem) { - titleLabel.text = item.operatorName.isEmpty ? "配置变更" : item.operatorName - detailLabel.text = item.displayText.isEmpty ? item.summary : item.displayText - timeLabel.text = item.createdAt - } - - private func setupUI() { - selectionStyle = .none - backgroundColor = .clear - card.backgroundColor = .white - card.layer.cornerRadius = ScenicQueueTokens.radiusCard - card.layer.borderWidth = 1 - card.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor - contentView.addSubview(card) - titleLabel.font = ScenicQueueTokens.settingsSectionTitleFont - titleLabel.textColor = AppColor.textPrimary - detailLabel.font = ScenicQueueTokens.settingsRowTitleFont - detailLabel.textColor = AppColor.textSecondary - detailLabel.numberOfLines = 0 - timeLabel.font = ScenicQueueTokens.settingsHintFont - timeLabel.textColor = AppColor.textTertiary - let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, timeLabel]) - stack.axis = .vertical - stack.spacing = 8 - card.addSubview(stack) - card.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16)) - } - stack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(14) - } + /// 绑定日志内容与时间轴圆点显隐。 + func configure(item: ScenicQueueSettingChangeLogItem, showDot: Bool) { + metaLabel.text = item.metadataLine + summaryLabel.text = item.cardSummary + dot.isHidden = !showDot } } diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift index 1d064e8..3f33230 100644 --- a/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift +++ b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift @@ -6,107 +6,108 @@ import SnapKit import UIKit -/// 排队设置页,对齐 Android `ScenicQueueSettingsScreen` 的三 Tab 表单。 +/// 排队设置页,按 Android `ScenicQueueSettingsScreen` 提供三页持久化表单。 final class ScenicQueueSettingsViewController: BaseViewController { - private let viewModel = ScenicQueueSettingsViewModel() + private let viewModel: ScenicQueueSettingsViewModel private let api: ScenicQueueAPIProtocol - - private let segmentedControl = UISegmentedControl(items: ScenicQueueSettingsTab.allCases.map(\.title)) - private let scrollView = UIScrollView() - private let stackView = UIStackView() + private let tabStrip = ScenicQueueTabStripView( + titles: ScenicQueueSettingsTab.allCases.map(\.title)) + private let pageController = UIPageViewController( + transitionStyle: .scroll, navigationOrientation: .horizontal) private let saveBar = UIView() private let saveButton = UIButton(type: .system) - - private var fieldRows: [String: ScenicQueueSettingFieldRow] = [:] - private var switchRows: [String: ScenicQueueSwitchRow] = [:] + private var pages: [ScenicQueueSettingsPageController] = [] + private var numericFields: [String: ScenicQueueNumericFieldView] = [:] + private var switches: [String: ScenicQueueSwitchRow] = [:] + private let punchSpotButton = UIButton(type: .system) + private var selectedPunchLabel = UILabel() + private var punchSpotOptionsLoading = false + private var punchSpotOptionsLoaded = false + private var startTimeButton = UIButton(type: .system) + private var endTimeButton = UIButton(type: .system) + private var customTextView = UITextView() + private var customPlayButton = UIButton(type: .system) + private var presetStack = UIStackView() + private var presetSummaryLabel = UILabel() @MainActor - init(api: ScenicQueueAPIProtocol? = nil) { + init( + api: ScenicQueueAPIProtocol? = nil, + viewModel: ScenicQueueSettingsViewModel? = nil + ) { self.api = api ?? NetworkServices.shared.scenicQueueAPI + self.viewModel = viewModel ?? ScenicQueueSettingsViewModel() super.init(nibName: nil, bundle: nil) } @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupNavigationBar() { title = "设置" navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "配置日志", - style: .plain, - target: self, - action: #selector(logTapped) - ) + title: "配置日志", style: .plain, target: self, action: #selector(logTapped)) } override func setupUI() { view.backgroundColor = ScenicQueueTokens.pageBackground - segmentedControl.selectedSegmentIndex = viewModel.settingsTab.rawValue - segmentedControl.selectedSegmentTintColor = .white - segmentedControl.setTitleTextAttributes([ - .font: ScenicQueueTokens.settingsRowTitleFont, - .foregroundColor: AppColor.textSecondary, - ], for: .normal) - segmentedControl.setTitleTextAttributes([ - .font: UIFont.systemFont(ofSize: 14, weight: .semibold), - .foregroundColor: ScenicQueueTokens.bannerBlue, - ], for: .selected) + pageController.dataSource = self + pageController.delegate = self + addChild(pageController) + view.addSubview(tabStrip) + view.addSubview(pageController.view) + pageController.didMove(toParent: self) - stackView.axis = .vertical - stackView.spacing = 14 - scrollView.keyboardDismissMode = .onDrag - scrollView.alwaysBounceVertical = true + pages = ScenicQueueSettingsTab.allCases.map { ScenicQueueSettingsPageController(tab: $0) } + buildBasicPage(pages[0]) + buildNotifyPage(pages[1]) + buildVoicePage(pages[2]) + pageController.setViewControllers([pages[0]], direction: .forward, animated: false) saveBar.backgroundColor = .white + saveBar.layer.shadowColor = UIColor.black.cgColor + saveBar.layer.shadowOpacity = 0.08 + saveBar.layer.shadowRadius = 6 + saveBar.layer.shadowOffset = CGSize(width: 0, height: -2) saveButton.setTitle("保存", for: .normal) saveButton.setTitleColor(.white, for: .normal) saveButton.titleLabel?.font = ScenicQueueTokens.settingsSaveButtonFont saveButton.backgroundColor = ScenicQueueTokens.bannerBlue saveButton.layer.cornerRadius = ScenicQueueTokens.bottomSaveButtonRadius - saveButton.clipsToBounds = true - - view.addSubview(segmentedControl) - view.addSubview(scrollView) - scrollView.addSubview(stackView) - view.addSubview(saveBar) saveBar.addSubview(saveButton) - rebuildForm() + view.addSubview(saveBar) + applyState() } override func setupConstraints() { - segmentedControl.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(8) - make.leading.trailing.equalToSuperview().inset(16) - make.height.equalTo(40) - } - saveBar.snp.makeConstraints { make in - make.leading.trailing.bottom.equalToSuperview() + tabStrip.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(48) } + saveBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } saveButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(31) - make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) make.height.equalTo(48) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) } - scrollView.snp.makeConstraints { make in - make.top.equalTo(segmentedControl.snp.bottom).offset(10) + pageController.view.snp.makeConstraints { make in + make.top.equalTo(tabStrip.snp.bottom) make.leading.trailing.equalToSuperview() make.bottom.equalTo(saveBar.snp.top) } - stackView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 16, right: 16)) - make.width.equalToSuperview().offset(-32) - } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + loadPunchSpotMenuIfNeeded() } override func bindActions() { - segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged) + tabStrip.onSelect = { [weak self] index in self?.selectPage(index, animated: true) } saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) - viewModel.onStateChange = { [weak self] in - Task { @MainActor in self?.applyState() } - } + viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } @@ -114,316 +115,814 @@ final class ScenicQueueSettingsViewController: BaseViewController { Task { @MainActor in self?.navigationController?.popViewController(animated: true) } } viewModel.onNavigateLog = { [weak self] in - Task { @MainActor in self?.navigationController?.pushViewController(ScenicQueueSettingChangeLogViewController(), animated: true) } + Task { @MainActor in + self?.navigationController?.pushViewController( + ScenicQueueSettingChangeLogViewController(), animated: true) + } } } + private func selectPage(_ index: Int, animated: Bool) { + guard pages.indices.contains(index) else { return } + let current = viewModel.settingsTab.rawValue + let direction: UIPageViewController.NavigationDirection = index >= current ? .forward : .reverse + viewModel.setSettingsTab(ScenicQueueSettingsTab(rawValue: index) ?? .basic) + pageController.setViewControllers([pages[index]], direction: direction, animated: animated) + tabStrip.setSelectedIndex(index, animated: animated) + } + private func applyState() { - segmentedControl.selectedSegmentIndex = viewModel.settingsTab.rawValue - rebuildForm() - } - - private func rebuildForm() { - stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - fieldRows.removeAll() - switchRows.removeAll() - switch viewModel.settingsTab { - case .basic: - buildBasicForm() - case .notify: - buildNotifyForm() - case .voice: - buildVoiceForm() + let selected = viewModel.settingsTab.rawValue + tabStrip.setSelectedIndex(selected, animated: true) + if pageController.viewControllers?.first !== pages[selected] { + pageController.setViewControllers([pages[selected]], direction: .forward, animated: false) } + selectedPunchLabel.text = viewModel.selectedPunchSpotDisplayText + configurePunchSpotMenu() + startTimeButton.setTitle(viewModel.businessStartTime, for: .normal) + endTimeButton.setTitle(viewModel.businessEndTime, for: .normal) + updateField( + "queueDistanceMeter", + ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: viewModel.queueDistanceMeter)) + updateField("shootMinute", "\(viewModel.shootMinute)") + updateField("shootSecond", "\(viewModel.shootSecond)") + updateField("queueTakeLimit", "\(viewModel.queueTakeLimit)") + updateField("missCallRequeueOffset", "\(viewModel.missCallRequeueOffset)") + updateField("autoCallAheadCount", "\(viewModel.autoCallAheadCount)") + updateField("firstNoticeAhead", "\(viewModel.firstAheadCount)") + updateField("secondNoticeAhead", "\(viewModel.secondAheadCount)") + updateField("broadcastIntervalSec", "\(viewModel.broadcastIntervalSec)") + updateField("countdownReadableThresholdSec", "\(viewModel.countdownThresholdSec)") + setSwitch("businessOpen", viewModel.businessOpen) + setSwitch("showShoot", viewModel.showStartShootingButton) + setSwitch("quickCall", viewModel.quickCallButtonEnabled) + setSwitch("prepareCall", viewModel.prepareCallButtonEnabled) + setSwitch("firstSms", viewModel.firstSms) + setSwitch("firstPhone", viewModel.firstPhone) + setSwitch("secondSms", viewModel.secondSms) + setSwitch("secondPhone", viewModel.secondPhone) + if !customTextView.isFirstResponder { customTextView.text = viewModel.customTTSText } + customTextView.isEditable = !viewModel.isCustomTTSLooping + customPlayButton.setTitle(viewModel.isCustomTTSLooping ? "停止播放" : "试听", for: .normal) + rebuildPresetRows() + viewModel.highlightedInvalidFieldKeys.forEach { numericFields[$0]?.setInvalid(true) } } - private func buildBasicForm() { - stackView.addArrangedSubview(tipCard()) - stackView.addArrangedSubview(actionCard(title: "打卡点", value: viewModel.selectedPunchSpotDisplayText, primary: "选择打卡点", secondary: "保存小程序码", primaryAction: #selector(punchSpotTapped), secondaryAction: #selector(saveQRTapped), invalid: viewModel.highlightedInvalidFieldKeys.contains("punchSpot"))) - addSwitchRow(key: "businessOpen", title: "营业状态", isOn: viewModel.businessOpen, action: #selector(businessOpenChanged(_:))) - addTextRow(key: "businessStart", title: "营业开始时间", value: viewModel.businessStartTime, suffix: "HH:mm", keyboard: .numbersAndPunctuation, action: #selector(businessStartChanged(_:))) - addTextRow(key: "businessEnd", title: "营业结束时间", value: viewModel.businessEndTime, suffix: "HH:mm", keyboard: .numbersAndPunctuation, action: #selector(businessEndChanged(_:))) - addTextRow(key: "queueDistance", title: "允许排队距离", value: "\(viewModel.queueDistanceMeter)", suffix: "米", keyboard: .numberPad, action: #selector(queueDistanceChanged(_:))) - addTextRow(key: "shootMinute", title: "拍摄时长(分钟)", value: "\(viewModel.shootMinute)", suffix: "分", keyboard: .numberPad, action: #selector(shootMinuteChanged(_:))) - addTextRow(key: "shootSecond", title: "拍摄时长(秒)", value: "\(viewModel.shootSecond)", suffix: "秒", keyboard: .numberPad, action: #selector(shootSecondChanged(_:))) - addTextRow(key: "queueTakeLimit", title: "单人取号次数", value: "\(viewModel.queueTakeLimit)", suffix: "次", keyboard: .numberPad, action: #selector(queueTakeLimitChanged(_:))) - addTextRow(key: "missOffset", title: "过号重排顺延位数", value: "\(viewModel.missCallRequeueOffset)", suffix: "位", keyboard: .numberPad, action: #selector(missOffsetChanged(_:))) - addSwitchRow(key: "showShoot", title: "展示开始拍摄按钮", isOn: viewModel.showStartShootingButton, action: #selector(showShootChanged(_:))) - addTextRow(key: "autoCall", title: "自动提前叫号数量", value: "\(viewModel.autoCallAheadCount)", suffix: "人", keyboard: .numberPad, action: #selector(autoCallChanged(_:))) - addSwitchRow(key: "quickCall", title: "底部快速叫号", isOn: viewModel.quickCallButtonEnabled, action: #selector(quickCallChanged(_:))) - addSwitchRow(key: "prepareCall", title: "底部准备叫号", isOn: viewModel.prepareCallButtonEnabled, action: #selector(prepareCallChanged(_:))) + private func updateField(_ key: String, _ value: String) { + let field = numericFields[key] + field?.setInvalid(viewModel.highlightedInvalidFieldKeys.contains(key)) + field?.setDisplayValue(value) } - private func buildNotifyForm() { - addTextRow(key: "firstAhead", title: "第一次提前通知阈值", value: "\(viewModel.firstAheadCount)", suffix: "位", keyboard: .numberPad, action: #selector(firstAheadChanged(_:))) - addSwitchRow(key: "firstSms", title: "第一次短信通知", isOn: viewModel.firstSms, action: #selector(firstSmsChanged(_:))) - addSwitchRow(key: "firstPhone", title: "第一次电话通知", isOn: viewModel.firstPhone, action: #selector(firstPhoneChanged(_:))) - addTextRow(key: "secondAhead", title: "第二次提前通知阈值", value: "\(viewModel.secondAheadCount)", suffix: "位", keyboard: .numberPad, action: #selector(secondAheadChanged(_:))) - addSwitchRow(key: "secondSms", title: "第二次短信通知", isOn: viewModel.secondSms, action: #selector(secondSmsChanged(_:))) - addSwitchRow(key: "secondPhone", title: "第二次电话通知", isOn: viewModel.secondPhone, action: #selector(secondPhoneChanged(_:))) + private func setSwitch(_ key: String, _ value: Bool) { switches[key]?.setValue(value) } + + private func buildBasicPage(_ page: ScenicQueueSettingsPageController) { + page.stack.addArrangedSubview(tipCard()) + page.stack.addArrangedSubview(punchSpotCard()) + page.stack.addArrangedSubview(businessCard()) + page.stack.addArrangedSubview( + sectionCard(title: "排队距离限制") { + let hint = self.hintLabel("顾客仅可在该距离范围内取号,0 表示不限制。合法范围 0~9999.99 千米。") + let field = self.numericField(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad) + return [hint, field] + }) + page.stack.addArrangedSubview( + sectionCard(title: "预计拍摄时长") { + let minute = self.numericField(key: "shootMinute", unit: "分") + let second = self.numericField(key: "shootSecond", unit: "秒") + let row = UIStackView(arrangedSubviews: [minute, second]) + row.axis = .horizontal + row.spacing = 12 + row.distribution = .fillEqually + return [self.hintLabel("总时长必须大于 0;分钟 0~30,秒 0~59。"), row] + }) + page.stack.addArrangedSubview( + sectionCard(title: "单人取号次数限制") { + [ + self.hintLabel("0 表示不限制,最大 999999 次。"), + self.numericField(key: "queueTakeLimit", unit: "次"), + ] + }) + page.stack.addArrangedSubview( + sectionCard(title: "过号重新排队") { + [ + self.hintLabel("设置过号重新排队时相对队首顺延的位数,范围 1~9999。"), + self.numericField(key: "missCallRequeueOffset", unit: "位"), + ] + }) + page.stack.addArrangedSubview( + sectionCard(title: "拍摄与叫号配置", info: true) { + let shoot = self.switchRow( + key: "showShoot", title: "显示开始拍摄按钮", action: #selector(self.showShootChanged(_:))) + let auto = self.numericField(key: "autoCallAheadCount", unit: "人") + return [shoot, self.divider(), self.hintLabel("开始拍摄后自动通知后续 0~5 个排队号。"), auto] + }) + page.stack.addArrangedSubview( + sectionCard(title: "底部叫号按钮") { + [ + self.switchRow( + key: "quickCall", title: "显示快速叫号", action: #selector(self.quickCallChanged(_:))), + self.divider(), + self.switchRow( + key: "prepareCall", title: "显示准备叫号", action: #selector(self.prepareCallChanged(_:))), + ] + }) } - private func buildVoiceForm() { - addTextRow(key: "broadcastInterval", title: "播报间隔时间", value: "\(viewModel.broadcastIntervalSec)", suffix: "秒", keyboard: .numberPad, action: #selector(broadcastChanged(_:))) - addTextRow(key: "countdown", title: "进入读秒倒计时阈值", value: "\(viewModel.countdownThresholdSec)", suffix: "秒", keyboard: .numberPad, action: #selector(countdownChanged(_:))) - addSwitchRow(key: "offlineTTS", title: "离线语音合成", isOn: viewModel.useOfflineTTS, action: #selector(offlineTTSChanged(_:))) - stackView.addArrangedSubview(actionCard(title: "蓝牙音响测试", value: "请确认外放音量是否合适", primary: "播放测试音", secondary: nil, primaryAction: #selector(testSoundTapped), secondaryAction: nil)) - addTextRow(key: "customTTS", title: "自定义播报文案", value: viewModel.customTTSText, suffix: "", keyboard: .default, action: #selector(customTTSChanged(_:))) - stackView.addArrangedSubview(actionCard(title: "播报文案", value: "最多保存5个语音", primary: "播放文本", secondary: "保存到预设", primaryAction: #selector(playCustomTapped), secondaryAction: #selector(savePresetTapped))) - for (index, phrase) in viewModel.presetVoices.enumerated() { - stackView.addArrangedSubview(presetVoiceCard(index: index, phrase: phrase)) - } + private func buildNotifyPage(_ page: ScenicQueueSettingsPageController) { + page.stack.addArrangedSubview( + sectionCard(title: "提前通知规则", info: true) { + [ + self.sectionLabel("第一次提前通知"), + self.hintLabel("前方人数达到阈值时触发,范围 0~50 位。"), + self.numericField(key: "firstNoticeAhead", unit: "位"), + self.switchRow( + key: "firstSms", title: "短信通知", action: #selector(self.firstSmsChanged(_:))), + self.switchRow( + key: "firstPhone", title: "电话通知(自动语音)", action: #selector(self.firstPhoneChanged(_:))), + self.divider(), self.sectionLabel("第二次提前通知"), + self.hintLabel("可设置更接近队首的二次提醒,范围 0~50 位。"), + self.numericField(key: "secondNoticeAhead", unit: "位"), + self.switchRow( + key: "secondSms", title: "短信通知", action: #selector(self.secondSmsChanged(_:))), + self.switchRow( + key: "secondPhone", title: "电话通知(自动语音)", action: #selector(self.secondPhoneChanged(_:))), + ] + }) } - private func addTextRow(key: String, title: String, value: String, suffix: String, keyboard: UIKeyboardType, action: Selector) { - let row = ScenicQueueSettingFieldRow(title: title, value: value, suffix: suffix, keyboard: keyboard) - row.textField.addTarget(self, action: action, for: .editingChanged) - fieldRows[key] = row - stackView.addArrangedSubview(row) - } - - private func addSwitchRow(key: String, title: String, isOn: Bool, action: Selector) { - let row = ScenicQueueSwitchRow(title: title, isOn: isOn) - row.toggle.addTarget(self, action: action, for: .valueChanged) - switchRows[key] = row - stackView.addArrangedSubview(row) + private func buildVoicePage(_ page: ScenicQueueSettingsPageController) { + page.stack.addArrangedSubview( + sectionCard(title: "倒计时播报配置", info: true) { + let test = self.primaryButton("播放测试声音", action: #selector(self.testSoundTapped)) + return [ + self.sectionLabel("播报间隔时间"), self.hintLabel("从开拍起按间隔播报剩余时间,合法范围 40~60 秒。"), + self.numericField(key: "broadcastIntervalSec", unit: "秒"), + self.sectionLabel("进入读秒倒计时阈值"), self.hintLabel("到达阈值后逐秒播报,合法范围 10~30 秒。"), + self.numericField(key: "countdownReadableThresholdSec", unit: "秒"), + self.sectionLabel("测试蓝牙音响"), test, + ] + }) + page.stack.addArrangedSubview(customTTSCard()) + page.stack.addArrangedSubview(presetVoicesCard()) } private func tipCard() -> UIView { let card = ScenicQueueSettingCard() card.backgroundColor = ScenicQueueTokens.bannerBlue - let title = UILabel() - title.text = "排队基础参数" + card.layer.borderWidth = 0 + let title = sectionLabel("温馨提示") title.textColor = .white - title.font = ScenicQueueTokens.settingsSectionTitleFont - let body = UILabel() - body.text = "选择打卡点后保存配置,顾客即可扫码取号排队。" - body.textColor = UIColor.white.withAlphaComponent(0.86) - body.font = ScenicQueueTokens.settingsHintFont - body.numberOfLines = 0 - let stack = UIStackView(arrangedSubviews: [title, body]) - stack.axis = .vertical - stack.spacing = 6 + let body = hintLabel("配置信息修改后,请及时测试叫号设备状态") + body.textColor = UIColor.white.withAlphaComponent(0.92) + let stack = verticalStack([title, body], spacing: 6) card.addSubview(stack) - stack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(14) + stack.snp.makeConstraints { + $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 20, left: 16, bottom: 20, right: 16)) } return card } - private func actionCard(title: String, value: String, primary: String, secondary: String?, primaryAction: Selector, secondaryAction: Selector?, invalid: Bool = false) -> UIView { + private func punchSpotCard() -> UIView { + sectionCard(title: "打卡点信息") { + let label = self.hintLabel("选择打卡点") + let selector = self.punchSpotButton + selector.backgroundColor = .white + selector.layer.cornerRadius = ScenicQueueTokens.radiusInput + selector.layer.borderWidth = 1 + selector.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor + selector.showsMenuAsPrimaryAction = true + self.selectedPunchLabel.font = ScenicQueueTokens.settingsRowTitleFont + self.selectedPunchLabel.textColor = AppColor.textPrimary + let arrow = UILabel() + arrow.text = "▼" + arrow.font = ScenicQueueTokens.settingsHintFont + arrow.textColor = AppColor.textTertiary + selector.addSubview(self.selectedPunchLabel) + selector.addSubview(arrow) + self.selectedPunchLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(arrow.snp.leading).offset(-8) + } + arrow.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-12) + make.centerY.equalToSuperview() + } + selector.snp.makeConstraints { $0.height.equalTo(52) } + let qrLabel = self.sectionLabel("点击保存打卡点排队二维码") + let qr = self.compactPrimaryButton("保存二维码", action: #selector(self.saveQRTapped)) + let qrRow = UIStackView(arrangedSubviews: [qrLabel, qr]) + qrRow.axis = .horizontal + qrRow.alignment = .center + qrRow.spacing = 10 + return [label, selector, qrRow] + } + } + + private func businessCard() -> UIView { + sectionCard(title: "排队状态与时间", info: true) { + let status = self.switchRow( + key: "businessOpen", title: "当前排队状态", action: #selector(self.businessOpenChanged(_:))) + self.startTimeButton = self.timeButton(action: #selector(self.startTimeTapped)) + self.endTimeButton = self.timeButton(action: #selector(self.endTimeTapped)) + let times = UIStackView(arrangedSubviews: [ + self.labeledControl("营业开始时间", self.startTimeButton), + self.labeledControl("营业结束时间", self.endTimeButton), + ]) + times.axis = .horizontal + times.distribution = .fillEqually + times.spacing = 12 + return [status, self.hintLabel("开启后顾客可在营业时间内扫码取号;关闭后暂停排队。"), self.divider(), times] + } + } + + private func customTTSCard() -> UIView { + sectionCard(title: "自定义文本语音播报") { + self.customTextView.font = ScenicQueueTokens.settingsRowTitleFont + self.customTextView.textColor = AppColor.textPrimary + self.customTextView.layer.cornerRadius = ScenicQueueTokens.radiusInput + self.customTextView.layer.borderWidth = 1 + self.customTextView.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor + self.customTextView.delegate = self + self.customTextView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8) + self.customTextView.snp.makeConstraints { $0.height.equalTo(104) } + self.customPlayButton = self.outlinedButton("试听", action: #selector(self.customPlayTapped)) + let save = self.primaryButton("保存", action: #selector(self.savePresetTapped)) + let row = UIStackView(arrangedSubviews: [self.customPlayButton, save]) + row.axis = .horizontal + row.distribution = .fillEqually + row.spacing = 12 + return [self.customTextView, row] + } + } + + private func presetVoicesCard() -> UIView { let card = ScenicQueueSettingCard() - card.layer.borderColor = invalid ? AppColor.danger.cgColor : ScenicQueueTokens.cardOutline.cgColor - let titleLabel = UILabel() - titleLabel.text = title - titleLabel.font = ScenicQueueTokens.settingsFieldLabelFont - titleLabel.textColor = AppColor.textPrimary - let valueLabel = UILabel() - valueLabel.text = value - valueLabel.font = ScenicQueueTokens.settingsHintFont - valueLabel.textColor = AppColor.textSecondary - valueLabel.numberOfLines = 0 - let primaryButton = UIButton(type: .system) - primaryButton.setTitle(primary, for: .normal) - primaryButton.titleLabel?.font = ScenicQueueTokens.buttonSecondaryFont - primaryButton.addTarget(self, action: primaryAction, for: .touchUpInside) - let buttons = UIStackView(arrangedSubviews: [primaryButton]) - buttons.axis = .horizontal - buttons.spacing = 12 - if let secondary, let secondaryAction { - let secondaryButton = UIButton(type: .system) - secondaryButton.setTitle(secondary, for: .normal) - secondaryButton.titleLabel?.font = ScenicQueueTokens.buttonSecondaryFont - secondaryButton.addTarget(self, action: secondaryAction, for: .touchUpInside) - buttons.addArrangedSubview(secondaryButton) - } - let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel, buttons]) - stack.axis = .vertical - stack.spacing = 8 + presetSummaryLabel.font = ScenicQueueTokens.settingsSectionTitleFont + presetSummaryLabel.textColor = AppColor.textPrimary + presetStack.axis = .vertical + presetStack.spacing = 10 + let hint = hintLabel("左滑显示删除;点左侧图标循环播报该条。底部「保存」提交全部排队参数。") + let stack = verticalStack([presetSummaryLabel, presetStack, hint], spacing: 14) card.addSubview(stack) - stack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(14) - } + stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) } return card } - private func presetVoiceCard(index: Int, phrase: String) -> UIView { + private func rebuildPresetRows() { + presetSummaryLabel.text = "已保存的语音(\(viewModel.presetVoices.count)/5)" + presetStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + guard !viewModel.presetVoices.isEmpty else { + presetStack.addArrangedSubview(hintLabel("暂无预设文案,输入后点击「保存」添加到本地列表(最多 5 条)。")) + return + } + for (index, phrase) in viewModel.presetVoices.enumerated() { + let playing = + viewModel.isCustomTTSLooping + && viewModel.ttsLoopAnchorText?.trimmingCharacters(in: .whitespacesAndNewlines) + == phrase.trimmingCharacters(in: .whitespacesAndNewlines) + let row = ScenicQueuePresetVoiceRow(text: phrase, playing: playing) + row.tag = index + row.onPlay = { [weak self] in + guard let self else { return } + if playing { + self.viewModel.toggleCustomTTSPlayback() + } else { + self.viewModel.startPresetVoiceLoop(phrase) + } + } + row.onDelete = { [weak self] in self?.viewModel.deletePresetVoice(at: index) } + presetStack.addArrangedSubview(row) + } + } + + private func sectionCard(title: String, info: Bool = false, content: () -> [UIView]) -> UIView { let card = ScenicQueueSettingCard() + let heading = sectionLabel(title) + if info { heading.text = "\(title) ⓘ" } + let stack = verticalStack([heading] + content(), spacing: 12) + card.addSubview(stack) + stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) } + return card + } + + private func numericField(key: String, unit: String, keyboard: UIKeyboardType = .numberPad) + -> ScenicQueueNumericFieldView + { + let field = ScenicQueueNumericFieldView(key: key, unit: unit, keyboard: keyboard) + field.delegate = self + numericFields[key] = field + return field + } + + private func switchRow(key: String, title: String, action: Selector) -> ScenicQueueSwitchRow { + let row = ScenicQueueSwitchRow(title: title) + row.toggle.addTarget(self, action: action, for: .valueChanged) + switches[key] = row + return row + } + + private func verticalStack(_ views: [UIView], spacing: CGFloat) -> UIStackView { + let stack = UIStackView(arrangedSubviews: views) + stack.axis = .vertical + stack.spacing = spacing + return stack + } + + private func sectionLabel(_ text: String) -> UILabel { let label = UILabel() - label.text = phrase - label.font = ScenicQueueTokens.settingsRowTitleFont + label.text = text + label.font = ScenicQueueTokens.settingsSectionTitleFont label.textColor = AppColor.textPrimary label.numberOfLines = 0 - let delete = UIButton(type: .system) - delete.setImage(UIImage(systemName: "trash"), for: .normal) - delete.tintColor = AppColor.danger - delete.tag = index - delete.addTarget(self, action: #selector(deletePresetTapped(_:)), for: .touchUpInside) - card.addSubview(label) - card.addSubview(delete) - label.snp.makeConstraints { make in - make.leading.top.bottom.equalToSuperview().inset(14) - make.trailing.equalTo(delete.snp.leading).offset(-12) - } - delete.snp.makeConstraints { make in - make.trailing.equalToSuperview().offset(-14) - make.centerY.equalToSuperview() - make.size.equalTo(44) - } - return card + return label } - @objc private func tabChanged() { - viewModel.setSettingsTab(ScenicQueueSettingsTab(rawValue: segmentedControl.selectedSegmentIndex) ?? .basic) + private func hintLabel(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = ScenicQueueTokens.settingsHintFont + label.textColor = AppColor.textTertiary + label.numberOfLines = 0 + return label + } + + private func divider() -> UIView { + let view = UIView() + view.backgroundColor = ScenicQueueTokens.cardOutline + view.snp.makeConstraints { $0.height.equalTo(1 / UIScreen.main.scale) } + return view + } + + private func primaryButton(_ title: String, action: Selector) -> UIButton { + let button = compactPrimaryButton(title, action: action) + button.snp.makeConstraints { $0.height.equalTo(44) } + return button + } + + private func compactPrimaryButton(_ title: String, action: Selector) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = ScenicQueueTokens.settingsRowTitleFont + button.backgroundColor = ScenicQueueTokens.bannerBlue + button.layer.cornerRadius = ScenicQueueTokens.radiusInput + button.addTarget(self, action: action, for: .touchUpInside) + button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + return button + } + + private func outlinedButton(_ title: String, action: Selector) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal) + button.titleLabel?.font = ScenicQueueTokens.settingsRowTitleFont + button.layer.cornerRadius = ScenicQueueTokens.radiusInput + button.layer.borderWidth = 1 + button.layer.borderColor = ScenicQueueTokens.bannerBlue.cgColor + button.addTarget(self, action: action, for: .touchUpInside) + button.snp.makeConstraints { $0.height.equalTo(44) } + return button + } + + private func timeButton(action: Selector) -> UIButton { + let button = outlinedButton("--:--", action: action) + button.setTitleColor(AppColor.textPrimary, for: .normal) + button.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor + return button + } + + private func labeledControl(_ title: String, _ control: UIView) -> UIView { + let label = hintLabel(title) + let stack = verticalStack([label, control], spacing: 8) + return stack + } + + private func loadPunchSpotMenuIfNeeded() { + guard !punchSpotOptionsLoaded, !punchSpotOptionsLoading else { return } + punchSpotOptionsLoading = true + configurePunchSpotMenu() + Task { + await viewModel.loadPunchSpotOptions(api: api) + punchSpotOptionsLoading = false + punchSpotOptionsLoaded = true + configurePunchSpotMenu() + } + } + + private func configurePunchSpotMenu() { + if punchSpotOptionsLoading { + punchSpotButton.menu = UIMenu(children: [ + UIAction(title: "正在加载打卡点…", attributes: .disabled) { _ in } + ]) + return + } + let actions = viewModel.punchSpotOptions.map { option in + UIAction( + title: option.label, + state: option.id == viewModel.selectedPunchSpotId ? .on : .off + ) { [weak self] _ in + guard let self else { return } + Task { await self.viewModel.setPunchSpot(option, api: self.api) } + } + } + punchSpotButton.menu = UIMenu( + options: .singleSelection, + children: actions.isEmpty + ? [UIAction(title: "暂无可选打卡点", attributes: .disabled) { _ in }] + : actions + ) + } + + private func presentTimePicker(initial: String, setter: @escaping (String) -> Void) { + present( + ScenicQueueTimePickerViewController(initialTime: initial, onConfirm: setter), animated: true) } @objc private func logTapped() { viewModel.openSettingChangeLog() } - @objc private func saveTapped() { view.endEditing(true) Task { await viewModel.save(api: api) } } - - @objc private func punchSpotTapped() { - Task { - await viewModel.loadPunchSpotOptions(api: api) - await MainActor.run { presentPunchSpotPicker() } - } - } - - private func presentPunchSpotPicker() { - let alert = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet) - viewModel.punchSpotOptions.forEach { option in - alert.addAction(UIAlertAction(title: option.label, style: .default) { [weak self] _ in - guard let self else { return } - Task { await self.viewModel.setPunchSpot(option, api: self.api) } - }) - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(alert, animated: true) - } - @objc private func saveQRTapped() { Task { await viewModel.saveShootQueueQRCode(api: api) } } + @objc private func startTimeTapped() { + presentTimePicker(initial: viewModel.businessStartTime, setter: viewModel.setBusinessStartTime) + } + @objc private func endTimeTapped() { + presentTimePicker(initial: viewModel.businessEndTime, setter: viewModel.setBusinessEndTime) + } @objc private func testSoundTapped() { viewModel.playBluetoothTestSound() } - @objc private func playCustomTapped() { viewModel.toggleCustomTTSPlayback() } - @objc private func savePresetTapped() { viewModel.saveCurrentTextAsPresetVoiceLocal() } - @objc private func deletePresetTapped(_ sender: UIButton) { viewModel.deletePresetVoice(at: sender.tag) } - + @objc private func customPlayTapped() { viewModel.toggleCustomTTSPlayback() } + @objc private func savePresetTapped() { + viewModel.setCustomTTSText(customTextView.text ?? "") + viewModel.saveCurrentTextAsPresetVoiceLocal() + } @objc private func businessOpenChanged(_ sender: UISwitch) { viewModel.toggleBusinessOpen() } - @objc private func showShootChanged(_ sender: UISwitch) { viewModel.toggleShowStartShootingButton() } - @objc private func quickCallChanged(_ sender: UISwitch) { viewModel.toggleQuickCallButtonEnabled() } - @objc private func prepareCallChanged(_ sender: UISwitch) { viewModel.togglePrepareCallButtonEnabled() } + @objc private func showShootChanged(_ sender: UISwitch) { + viewModel.toggleShowStartShootingButton() + } + @objc private func quickCallChanged(_ sender: UISwitch) { + viewModel.toggleQuickCallButtonEnabled() + } + @objc private func prepareCallChanged(_ sender: UISwitch) { + viewModel.togglePrepareCallButtonEnabled() + } @objc private func firstSmsChanged(_ sender: UISwitch) { viewModel.toggleFirstSms() } @objc private func firstPhoneChanged(_ sender: UISwitch) { viewModel.toggleFirstPhone() } @objc private func secondSmsChanged(_ sender: UISwitch) { viewModel.toggleSecondSms() } @objc private func secondPhoneChanged(_ sender: UISwitch) { viewModel.toggleSecondPhone() } - - @objc private func businessStartChanged(_ sender: UITextField) { viewModel.setBusinessStartTime(sender.text ?? "") } - @objc private func businessEndChanged(_ sender: UITextField) { viewModel.setBusinessEndTime(sender.text ?? "") } - @objc private func queueDistanceChanged(_ sender: UITextField) { viewModel.setQueueDistanceMeter(Int(sender.text ?? "") ?? 0) } - @objc private func shootMinuteChanged(_ sender: UITextField) { viewModel.setShootMinute(Int(sender.text ?? "") ?? 0) } - @objc private func shootSecondChanged(_ sender: UITextField) { viewModel.setShootSecond(Int(sender.text ?? "") ?? 0) } - @objc private func queueTakeLimitChanged(_ sender: UITextField) { viewModel.setQueueTakeLimit(Int(sender.text ?? "") ?? 0) } - @objc private func missOffsetChanged(_ sender: UITextField) { viewModel.setMissCallRequeueOffset(Int(sender.text ?? "") ?? 1) } - @objc private func autoCallChanged(_ sender: UITextField) { viewModel.setAutoCallAheadCount(Int(sender.text ?? "") ?? 0) } - @objc private func firstAheadChanged(_ sender: UITextField) { viewModel.setFirstAheadCount(Int(sender.text ?? "") ?? 0) } - @objc private func secondAheadChanged(_ sender: UITextField) { viewModel.setSecondAheadCount(Int(sender.text ?? "") ?? 0) } - @objc private func broadcastChanged(_ sender: UITextField) { viewModel.setBroadcastIntervalSec(Int(sender.text ?? "") ?? 50) } - @objc private func countdownChanged(_ sender: UITextField) { viewModel.setCountdownThresholdSec(Int(sender.text ?? "") ?? 15) } - @objc private func offlineTTSChanged(_ sender: UISwitch) { viewModel.setUseOfflineTTS(sender.isOn) } - @objc private func customTTSChanged(_ sender: UITextField) { viewModel.setCustomTTSText(sender.text ?? "") } } -/// 设置页卡片容器。 -class ScenicQueueSettingCard: UIView { +extension ScenicQueueSettingsViewController: UIPageViewControllerDataSource, + UIPageViewControllerDelegate +{ + func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController + ) -> UIViewController? { + guard let page = viewController as? ScenicQueueSettingsPageController, + page.settingsTab.rawValue > 0 + else { return nil } + return pages[page.settingsTab.rawValue - 1] + } + + func pageViewController( + _ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController + ) -> UIViewController? { + guard let page = viewController as? ScenicQueueSettingsPageController, + page.settingsTab.rawValue < pages.count - 1 + else { return nil } + return pages[page.settingsTab.rawValue + 1] + } + + func pageViewController( + _ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], transitionCompleted completed: Bool + ) { + guard completed, + let page = pageViewController.viewControllers?.first as? ScenicQueueSettingsPageController + else { return } + viewModel.setSettingsTab(page.settingsTab) + tabStrip.setSelectedIndex(page.settingsTab.rawValue, animated: true) + } +} + +extension ScenicQueueSettingsViewController: ScenicQueueNumericFieldViewDelegate { + func numericFieldView(_ field: ScenicQueueNumericFieldView, commit raw: String, key: String) + -> Bool + { + func integer(_ range: ClosedRange, setter: (Int) -> Void) -> Bool { + guard let value = Int(raw), range.contains(value) else { return false } + setter(value) + return true + } + let valid: Bool + switch key { + case "queueDistanceMeter": + if let meters = ScenicQueueSettingsViewModel.meters(fromKilometersText: raw) { + viewModel.setQueueDistanceMeter(meters) + valid = true + } else { + valid = false + } + case "shootMinute": valid = integer(0...30, setter: viewModel.setShootMinute) + case "shootSecond": valid = integer(0...59, setter: viewModel.setShootSecond) + case "queueTakeLimit": valid = integer(0...999_999, setter: viewModel.setQueueTakeLimit) + case "missCallRequeueOffset": + valid = integer(1...9_999, setter: viewModel.setMissCallRequeueOffset) + case "autoCallAheadCount": valid = integer(0...5, setter: viewModel.setAutoCallAheadCount) + case "firstNoticeAhead": valid = integer(0...50, setter: viewModel.setFirstAheadCount) + case "secondNoticeAhead": valid = integer(0...50, setter: viewModel.setSecondAheadCount) + case "broadcastIntervalSec": valid = integer(40...60, setter: viewModel.setBroadcastIntervalSec) + case "countdownReadableThresholdSec": + valid = integer(10...30, setter: viewModel.setCountdownThresholdSec) + default: valid = false + } + viewModel.reportOutOfRangeNumericPending(fieldKey: key, pending: !valid) + if !valid { showToast("输入值超出允许范围") } + return valid + } +} + +extension ScenicQueueSettingsViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + if textView.text.count > 255 { textView.text = String(textView.text.prefix(255)) } + viewModel.setCustomTTSText(textView.text) + } +} + +/// 设置页单个分页的持久化滚动容器。 +final class ScenicQueueSettingsPageController: UIViewController { + let settingsTab: ScenicQueueSettingsTab + let stack = UIStackView() + private let scrollView = UIScrollView() + + init(tab: ScenicQueueSettingsTab) { + self.settingsTab = tab + super.init(nibName: nil, bundle: nil) + } + @available(*, unavailable) required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = ScenicQueueTokens.pageBackground + scrollView.keyboardDismissMode = .onDrag + scrollView.alwaysBounceVertical = true + stack.axis = .vertical + stack.spacing = 14 + view.addSubview(scrollView) + scrollView.addSubview(stack) + scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 16, right: 16)) + make.width.equalToSuperview().offset(-32) + } + } +} + +/// 设置页白色描边卡片。 +final class ScenicQueueSettingCard: UIView { init() { super.init(frame: .zero) backgroundColor = .white layer.cornerRadius = ScenicQueueTokens.radiusCard layer.borderWidth = 1 layer.borderColor = ScenicQueueTokens.cardOutline.cgColor - clipsToBounds = true } - - @available(*, unavailable) - required init?(coder: NSCoder) { + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } -/// 设置页文本字段行。 -final class ScenicQueueSettingFieldRow: ScenicQueueSettingCard { - let textField = UITextField() +/// 数字输入视图提交协议。 +protocol ScenicQueueNumericFieldViewDelegate: AnyObject { + func numericFieldView(_ field: ScenicQueueNumericFieldView, commit raw: String, key: String) + -> Bool +} - init(title: String, value: String, suffix: String, keyboard: UIKeyboardType) { - super.init() - let label = UILabel() - label.text = title - label.font = ScenicQueueTokens.settingsFieldLabelFont - label.textColor = AppColor.textPrimary - textField.text = value +/// Android OutlinedTextField 样式的数字与单位输入视图。 +final class ScenicQueueNumericFieldView: UIView, UITextFieldDelegate { + let key: String + weak var delegate: ScenicQueueNumericFieldViewDelegate? + private let textField = UITextField() + private var invalid = false + + init(key: String, unit: String, keyboard: UIKeyboardType) { + self.key = key + super.init(frame: .zero) + let unitLabel = UILabel() + unitLabel.text = unit + unitLabel.font = ScenicQueueTokens.settingsHintFont + unitLabel.textColor = AppColor.textSecondary + unitLabel.textAlignment = .right + unitLabel.setContentHuggingPriority(.required, for: .horizontal) + unitLabel.setContentCompressionResistancePriority(.required, for: .horizontal) textField.keyboardType = keyboard - textField.textAlignment = .right textField.font = ScenicQueueTokens.settingsRowTitleFont textField.textColor = AppColor.textPrimary - let suffixLabel = UILabel() - suffixLabel.text = suffix - suffixLabel.font = ScenicQueueTokens.settingsHintFont - suffixLabel.textColor = AppColor.textSecondary - addSubview(label) + textField.delegate = self + layer.cornerRadius = ScenicQueueTokens.radiusInput + layer.borderWidth = 1 + layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor addSubview(textField) - addSubview(suffixLabel) - label.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(14) - make.centerY.equalToSuperview() - } - suffixLabel.snp.makeConstraints { make in - make.trailing.equalToSuperview().offset(-14) + addSubview(unitLabel) + let focusTap = UITapGestureRecognizer(target: self, action: #selector(focusInput)) + focusTap.cancelsTouchesInView = false + addGestureRecognizer(focusTap) + unitLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(8) make.centerY.equalToSuperview() } textField.snp.makeConstraints { make in - make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12) - make.trailing.equalTo(suffixLabel.snp.leading).offset(-6) - make.top.bottom.equalToSuperview().inset(8) - make.height.equalTo(38) - make.width.greaterThanOrEqualTo(90) + make.leading.equalToSuperview().offset(12) + make.trailing.equalTo(unitLabel.snp.leading).offset(-8) + make.top.bottom.equalToSuperview() } + snp.makeConstraints { $0.height.equalTo(52) } + } + @available(*, unavailable) required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + func setDisplayValue(_ value: String) { + if !textField.isFirstResponder && !invalid { textField.text = value } + } + func setInvalid(_ value: Bool) { + invalid = value + layer.borderColor = (value ? AppColor.danger : ScenicQueueTokens.fieldBorderMuted).cgColor + } + @objc private func focusInput() { textField.becomeFirstResponder() } + func textFieldDidBeginEditing(_ textField: UITextField) { + layer.borderColor = ScenicQueueTokens.bannerBlue.cgColor + } + func textFieldDidEndEditing(_ textField: UITextField) { + let ok = delegate?.numericFieldView(self, commit: textField.text ?? "", key: key) ?? false + setInvalid(!ok) } } -/// 设置页开关行。 -final class ScenicQueueSwitchRow: ScenicQueueSettingCard { +/// 设置页标题与开关行。 +final class ScenicQueueSwitchRow: UIView { let toggle = UISwitch() - - init(title: String, isOn: Bool) { - super.init() + init(title: String) { + super.init(frame: .zero) let label = UILabel() label.text = title - label.font = ScenicQueueTokens.settingsFieldLabelFont + label.font = ScenicQueueTokens.settingsRowTitleFont label.textColor = AppColor.textPrimary - toggle.isOn = isOn + label.numberOfLines = 0 toggle.onTintColor = ScenicQueueTokens.bannerBlue addSubview(label) addSubview(toggle) label.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(14) - make.centerY.equalToSuperview() - make.top.bottom.equalToSuperview().inset(14) - } - toggle.snp.makeConstraints { make in - make.trailing.equalToSuperview().offset(-14) - make.centerY.equalToSuperview() - make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12) + make.leading.top.bottom.equalToSuperview() + make.trailing.lessThanOrEqualTo(toggle.snp.leading).offset(-12) } + toggle.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() } + snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) } } - - @available(*, unavailable) - required init?(coder: NSCoder) { + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + func setValue(_ value: Bool) { toggle.setOn(value, animated: false) } +} + +/// 已保存语音的播放与左滑删除行。 +final class ScenicQueuePresetVoiceRow: UIView { + var onPlay: (() -> Void)? + var onDelete: (() -> Void)? + private let content = UIView() + private let deleteButton = UIButton(type: .system) + private var revealed = false + + init(text: String, playing: Bool) { + super.init(frame: .zero) + layer.cornerRadius = ScenicQueueTokens.radiusInput + clipsToBounds = true + deleteButton.setTitle("删除", for: .normal) + deleteButton.setTitleColor(.white, for: .normal) + deleteButton.backgroundColor = UIColor(hex: 0xE53935) + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + content.backgroundColor = .white + content.layer.borderWidth = 1 + content.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor + content.layer.cornerRadius = ScenicQueueTokens.radiusInput + let play = UIButton(type: .system) + play.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal) + play.tintColor = ScenicQueueTokens.bannerBlue + play.addTarget(self, action: #selector(playTapped), for: .touchUpInside) + let label = UILabel() + label.text = text + label.font = ScenicQueueTokens.settingsRowTitleFont + label.textColor = AppColor.textPrimary + label.numberOfLines = 3 + addSubview(deleteButton) + addSubview(content) + content.addSubview(play) + content.addSubview(label) + deleteButton.snp.makeConstraints { make in + make.trailing.top.bottom.equalToSuperview() + make.width.equalTo(80) + } + content.snp.makeConstraints { $0.edges.equalToSuperview() } + play.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(4) + make.centerY.equalToSuperview() + make.size.equalTo(44) + } + label.snp.makeConstraints { make in + make.leading.equalTo(play.snp.trailing).offset(4) + make.trailing.equalToSuperview().offset(-12) + make.top.bottom.equalToSuperview().inset(12) + } + addGestureRecognizer(UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))) + (gestureRecognizers?.last as? UISwipeGestureRecognizer)?.direction = .left + let right = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:))) + right.direction = .right + addGestureRecognizer(right) + snp.makeConstraints { $0.height.greaterThanOrEqualTo(52) } + } + @available(*, unavailable) required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + @objc private func playTapped() { onPlay?() } + @objc private func deleteTapped() { onDelete?() } + @objc private func swiped(_ gesture: UISwipeGestureRecognizer) { + revealed = gesture.direction == .left + content.snp.updateConstraints { make in + make.leading.equalToSuperview().offset(revealed ? -80 : 0) + make.trailing.equalToSuperview().offset(revealed ? -80 : 0) + } + UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() } + } +} + +/// 营业时间的 Android 风格居中时间选择弹层。 +final class ScenicQueueTimePickerViewController: UIViewController { + private let onConfirm: (String) -> Void + private let picker = UIDatePicker() + init(initialTime: String, onConfirm: @escaping (String) -> Void) { + self.onConfirm = onConfirm + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + picker.date = formatter.date(from: initialTime) ?? Date() + } + @available(*, unavailable) required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.black.withAlphaComponent(0.42) + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = ScenicQueueTokens.radiusDialog + picker.datePickerMode = .time + picker.preferredDatePickerStyle = .wheels + picker.locale = Locale(identifier: "zh_CN") + let cancel = button("取消", filled: false, action: #selector(cancelTapped)) + let confirm = button("确认", filled: true, action: #selector(confirmTapped)) + let row = UIStackView(arrangedSubviews: [cancel, confirm]) + row.axis = .horizontal + row.spacing = 12 + row.distribution = .fillEqually + view.addSubview(card) + card.addSubview(picker) + card.addSubview(row) + card.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(32) + } + picker.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(190) + } + row.snp.makeConstraints { make in + make.top.equalTo(picker.snp.bottom) + make.leading.trailing.bottom.equalToSuperview().inset(20) + make.height.equalTo(44) + } + } + private func button(_ title: String, filled: Bool, action: Selector) -> UIButton { + let b = UIButton(type: .system) + b.setTitle(title, for: .normal) + b.setTitleColor(filled ? .white : ScenicQueueTokens.bannerBlue, for: .normal) + b.backgroundColor = + filled ? ScenicQueueTokens.bannerBlue : ScenicQueueTokens.dialogCancelBackground + b.layer.cornerRadius = 8 + b.addTarget(self, action: action, for: .touchUpInside) + return b + } + @objc private func cancelTapped() { dismiss(animated: true) } + @objc private func confirmTapped() { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + let value = formatter.string(from: picker.date) + dismiss(animated: true) { self.onConfirm(value) } + } } diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift index a4637a3..16bcd3f 100644 --- a/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift +++ b/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift @@ -15,12 +15,13 @@ final class ScenicQueueViewController: BaseViewController { private let punchSpotLabel = UILabel() private let statsBanner = ScenicQueueStatsBannerView() private let contentContainer = UIView() - private let segmentedControl = UISegmentedControl(items: ["当前排队", "过号列表"]) + private let tabStrip = ScenicQueueTabStripView(titles: ["当前排队", "过号列表"]) private let tableView = UITableView(frame: .zero, style: .plain) private let bottomBar = UIView() private let prepareCallButton = UIButton(type: .system) private let quickCallButton = UIButton(type: .system) private let emptyLabel = UILabel() + private var dataSource: UITableViewDiffableDataSource! private var selectedTab = 0 @@ -64,8 +65,8 @@ final class ScenicQueueViewController: BaseViewController { view.backgroundColor = ScenicQueueTokens.pageBackground punchSpotRow.backgroundColor = .white - let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse")) - locationIcon.tintColor = ScenicQueueTokens.bannerBlue + let locationIcon = UIImageView(image: UIImage(named: "queue_location")) + locationIcon.contentMode = .scaleAspectFit punchSpotLabel.font = .systemFont(ofSize: 16) punchSpotLabel.textColor = UIColor.black.withAlphaComponent(0.7) punchSpotLabel.lineBreakMode = .byTruncatingTail @@ -75,29 +76,21 @@ final class ScenicQueueViewController: BaseViewController { contentContainer.backgroundColor = .white contentContainer.layer.cornerRadius = ScenicQueueTokens.radiusContentSheetTop contentContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - contentContainer.clipsToBounds = true - - segmentedControl.selectedSegmentIndex = 0 - segmentedControl.selectedSegmentTintColor = .white - segmentedControl.setTitleTextAttributes([ - .font: ScenicQueueTokens.tabFont, - .foregroundColor: AppColor.textSecondary, - ], for: .normal) - segmentedControl.setTitleTextAttributes([ - .font: ScenicQueueTokens.tabSelectedFont, - .foregroundColor: ScenicQueueTokens.bannerBlue, - ], for: .selected) + contentContainer.layer.shadowColor = UIColor.black.cgColor + contentContainer.layer.shadowOpacity = 0.08 + contentContainer.layer.shadowRadius = 4 + contentContainer.layer.shadowOffset = CGSize(width: 0, height: 2) tableView.backgroundColor = .white tableView.separatorStyle = .none tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 260 tableView.delegate = self - tableView.dataSource = self tableView.register(ScenicQueueTicketCell.self, forCellReuseIdentifier: ScenicQueueTicketCell.reuseIdentifier) tableView.register(ScenicQueueSkippedCell.self, forCellReuseIdentifier: ScenicQueueSkippedCell.reuseIdentifier) tableView.refreshControl = UIRefreshControl() tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) + configureDataSource() emptyLabel.textColor = AppColor.textTertiary emptyLabel.font = ScenicQueueTokens.emptyStateFont @@ -114,7 +107,7 @@ final class ScenicQueueViewController: BaseViewController { view.addSubview(punchSpotRow) view.addSubview(statsBanner) view.addSubview(contentContainer) - contentContainer.addSubview(segmentedControl) + contentContainer.addSubview(tabStrip) contentContainer.addSubview(tableView) contentContainer.addSubview(emptyLabel) view.addSubview(bottomBar) @@ -149,12 +142,13 @@ final class ScenicQueueViewController: BaseViewController { make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(bottomBar.snp.top).offset(-8) } - segmentedControl.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview().inset(12) - make.height.equalTo(40) + tabStrip.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(12) + make.height.equalTo(48) } tableView.snp.makeConstraints { make in - make.top.equalTo(segmentedControl.snp.bottom).offset(8) + make.top.equalTo(tabStrip.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } emptyLabel.snp.makeConstraints { make in @@ -177,7 +171,7 @@ final class ScenicQueueViewController: BaseViewController { } override func bindActions() { - segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged) + tabStrip.onSelect = { [weak self] index in self?.selectTab(index) } prepareCallButton.addTarget(self, action: #selector(prepareCallTapped), for: .touchUpInside) quickCallButton.addTarget(self, action: #selector(quickCallTapped), for: .touchUpInside) viewModel.onStateChange = { [weak self] in @@ -202,7 +196,7 @@ final class ScenicQueueViewController: BaseViewController { punchSpotLabel.text = viewModel.selectedPunchSpotLine statsBanner.configure(summary: viewModel.summary) tableView.refreshControl?.endRefreshing() - tableView.reloadData() + applySnapshot(animated: true) let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录" emptyLabel.isHidden = !isEmpty @@ -212,6 +206,41 @@ final class ScenicQueueViewController: BaseViewController { bottomBar.isHidden = !viewModel.queueGatePassed || (!viewModel.prepareCallButtonEnabled && !viewModel.quickCallButtonEnabled) } + private func configureDataSource() { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in + guard let self else { return UITableViewCell() } + switch item { + case .current(let ticket): + let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell + cell.delegate = self + cell.configure( + ticket: ticket, + shootingQueueNo: self.viewModel.shootingTimedQueueNo, + remainSeconds: self.viewModel.shootingRemainSeconds, + showStartShootingButton: self.viewModel.showStartShootingButton + ) + return cell + case .skipped(let ticket): + let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell + cell.delegate = self + cell.configure(ticket: ticket) + return cell + } + } + } + + private func applySnapshot(animated: Bool) { + let previousItems = Set(dataSource.snapshot().itemIdentifiers) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + let items: [ScenicQueueListItem] = selectedTab == 0 + ? viewModel.currentQueue.map(ScenicQueueListItem.current) + : viewModel.skippedQueue.map(ScenicQueueListItem.skipped) + snapshot.appendItems(items) + snapshot.reconfigureItems(items.filter(previousItems.contains)) + dataSource.apply(snapshot, animatingDifferences: animated) + } + private func configureBottomButton(_ button: UIButton, title: String, subtitle: String, image: UIImage?, color: UIColor) { var config = UIButton.Configuration.filled() config.baseBackgroundColor = color @@ -232,42 +261,63 @@ final class ScenicQueueViewController: BaseViewController { private func presentParametersRequiredDialog() { guard presentedViewController == nil else { return } - let alert = UIAlertController(title: "排队参数未配置", message: "请先选择打卡点并保存排队设置", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "暂不进入", style: .cancel) { [weak self] _ in - self?.viewModel.exitQueueModule() - }) - alert.addAction(UIAlertAction(title: "去设置", style: .default) { [weak self] _ in - self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) - }) - present(alert, animated: true) + let dialog = ScenicQueueConfirmationDialogViewController( + configuration: ScenicQueueDialogConfiguration( + title: "请先设置排队参数", + message: "请在排队设置中选择并保存打卡点后,再使用排队管理。", + emphasizedText: nil, + dangerText: nil, + cancelTitle: "暂不使用", + confirmTitle: "去设置", + buttonRadius: 22, + dismissOnBackdrop: false + ), + onCancel: { [weak self] in self?.viewModel.exitQueueModule() }, + onConfirm: { [weak self] in self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) } + ) + present(dialog, animated: true) } - private func presentConfirm(title: String, message: String?, confirm: @escaping () -> Void) { - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in confirm() }) - present(alert, animated: true) + private func presentConfirm( + title: String, + emphasizedText: String, + dangerText: String? = nil, + confirmTitle: String = "确认", + cancel: @escaping () -> Void, + confirm: @escaping () -> Void + ) { + let dialog = ScenicQueueConfirmationDialogViewController( + configuration: ScenicQueueDialogConfiguration( + title: title, + message: nil, + emphasizedText: emphasizedText, + dangerText: dangerText, + cancelTitle: "取消", + confirmTitle: confirmTitle, + buttonRadius: title == "确认完成拍摄" ? 22 : 8, + dismissOnBackdrop: true + ), + onCancel: cancel, + onConfirm: confirm + ) + present(dialog, animated: true) } private func openDial(_ digits: String) { guard !digits.isEmpty else { return } - let alert = UIAlertController(title: "拨打电话", message: digits, preferredStyle: .actionSheet) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "拨打", style: .default) { _ in - if let url = URL(string: "tel:\(digits)") { - UIApplication.shared.open(url) - } - }) - present(alert, animated: true) + present(ScenicQueueDialSheetViewController(digits: digits), animated: true) } @objc private func settingsTapped() { viewModel.openSettings() } - @objc private func tabChanged() { - selectedTab = segmentedControl.selectedSegmentIndex - tableView.reloadData() + private func selectTab(_ index: Int) { + guard index != selectedTab else { return } + selectedTab = index + UIView.transition(with: tableView, duration: 0.22, options: .transitionCrossDissolve) { + self.applySnapshot(animated: false) + } applyState() } @@ -298,30 +348,7 @@ final class ScenicQueueViewController: BaseViewController { } } -extension ScenicQueueViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - selectedTab == 0 ? viewModel.currentQueue.count : viewModel.skippedQueue.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if selectedTab == 0 { - let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell - let ticket = viewModel.currentQueue[indexPath.row] - cell.delegate = self - cell.configure( - ticket: ticket, - shootingQueueNo: viewModel.shootingTimedQueueNo, - remainSeconds: viewModel.shootingRemainSeconds, - showStartShootingButton: viewModel.showStartShootingButton - ) - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell - cell.delegate = self - cell.configure(ticket: viewModel.skippedQueue[indexPath.row]) - return cell - } - +extension ScenicQueueViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return } Task { @@ -341,7 +368,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate { func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { viewModel.requestSkipTicket(recordId: ticket.recordId, queueNo: ticket.queueNo) - presentConfirm(title: "确认过号", message: ticket.queueNo) { [weak self] in + presentConfirm(title: "确认过号", emphasizedText: ticket.queueNo, dangerText: "确认该用户未到场并过号", cancel: { [weak self] in + self?.viewModel.dismissSkipDialog() + }) { [weak self] in guard let self else { return } Task { await self.viewModel.confirmSkipTicket(api: self.api) } } @@ -350,7 +379,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate { func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { if ticket.status == .waiting { viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo) - presentConfirm(title: "确认叫号", message: ticket.queueNo) { [weak self] in + presentConfirm(title: "确认叫号", emphasizedText: "确认叫号【\(ticket.queueNo)】", cancel: { [weak self] in + self?.viewModel.dismissCallConfirmDialog() + }) { [weak self] in guard let self else { return } Task { await self.viewModel.confirmCallTicket(api: self.api) } } @@ -361,7 +392,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate { func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo, isRecall: true) - presentConfirm(title: "重新叫号", message: ticket.queueNo) { [weak self] in + presentConfirm(title: "重新叫号", emphasizedText: "确认叫号【\(ticket.queueNo)】", cancel: { [weak self] in + self?.viewModel.dismissCallConfirmDialog() + }) { [weak self] in guard let self else { return } Task { await self.viewModel.confirmCallTicket(api: self.api) } } @@ -369,7 +402,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate { func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { viewModel.requestFinishConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo) - presentConfirm(title: "确认完成拍摄", message: ticket.queueNo) { [weak self] in + presentConfirm(title: "确认完成拍摄", emphasizedText: ticket.queueNo, cancel: { [weak self] in + self?.viewModel.dismissFinishDialog() + }) { [weak self] in guard let self else { return } Task { await self.viewModel.confirmFinishTicket(api: self.api) } } @@ -377,25 +412,16 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate { func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { viewModel.onTicketMarkClick(recordId: ticket.recordId, queueNo: ticket.queueNo, uid: ticket.uid) - let alert = UIAlertController(title: "标记用户", message: "\(ticket.queueNo)\n\(ticket.phoneMasked)", preferredStyle: .alert) - alert.addTextField { field in - field.placeholder = "限制排队天数" - field.keyboardType = .numberPad - field.text = "0" - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in - self?.viewModel.dismissMarkDialog() - }) - alert.addAction(UIAlertAction(title: "标记打野", style: .default) { [weak self, weak alert] _ in - guard let self else { return } - let days = Int(alert?.textFields?.first?.text ?? "") ?? 0 - Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: true, queueBanDays: days) } - }) - alert.addAction(UIAlertAction(title: "取消标记", style: .destructive) { [weak self] _ in - guard let self else { return } - Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: false, queueBanDays: 0) } - }) - present(alert, animated: true) + guard let pending = viewModel.pendingMark else { return } + let dialog = ScenicQueueMarkDialogViewController( + pending: pending, + onCancel: { [weak self] in self?.viewModel.dismissMarkDialog() }, + onConfirm: { [weak self] marked, days in + guard let self else { return } + Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: marked, queueBanDays: days) } + } + ) + present(dialog, animated: true) } } @@ -406,7 +432,9 @@ extension ScenicQueueViewController: ScenicQueueSkippedCellDelegate { func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) { viewModel.requestRequeueConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo) - presentConfirm(title: "确认重新排队", message: ticket.queueNo) { [weak self] in + presentConfirm(title: "确认重新排队", emphasizedText: ticket.queueNo, dangerText: "确认后将插入当前排队队列", confirmTitle: "确定", cancel: { [weak self] in + self?.viewModel.dismissRequeueDialog() + }) { [weak self] in guard let self else { return } Task { await self.viewModel.confirmRequeueTicket(api: self.api) } } diff --git a/suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift b/suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift new file mode 100644 index 0000000..ec337da --- /dev/null +++ b/suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift @@ -0,0 +1,396 @@ +// +// ScenicQueueOverlayViews.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// Android 排队模块样式的等宽标签栏。 +final class ScenicQueueTabStripView: UIView { + private let stackView = UIStackView() + private let indicator = UIView() + private let divider = UIView() + private var buttons: [UIButton] = [] + + /// 用户选择标签时回传索引。 + var onSelect: ((Int) -> Void)? + + /// 当前选中索引。 + private(set) var selectedIndex = 0 + + init(titles: [String]) { + super.init(frame: .zero) + backgroundColor = .white + stackView.axis = .horizontal + stackView.distribution = .fillEqually + titles.enumerated().forEach { index, title in + let button = UIButton(type: .system) + button.tag = index + button.setTitle(title, for: .normal) + button.titleLabel?.font = ScenicQueueTokens.tabFont + button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside) + buttons.append(button) + stackView.addArrangedSubview(button) + } + indicator.backgroundColor = ScenicQueueTokens.bannerBlue + divider.backgroundColor = ScenicQueueTokens.cardOutline + addSubview(stackView) + addSubview(divider) + addSubview(indicator) + stackView.snp.makeConstraints { $0.edges.equalToSuperview() } + divider.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(1 / UIScreen.main.scale) + } + setSelectedIndex(0, animated: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + updateIndicatorFrame() + } + + /// 更新选中标签并移动 3pt 蓝色指示条。 + func setSelectedIndex(_ index: Int, animated: Bool) { + guard buttons.indices.contains(index) else { return } + selectedIndex = index + buttons.enumerated().forEach { item in + item.element.setTitleColor(item.offset == index ? ScenicQueueTokens.bannerBlue : AppColor.textSecondary, for: .normal) + item.element.titleLabel?.font = item.offset == index ? ScenicQueueTokens.tabSelectedFont : ScenicQueueTokens.tabFont + } + let changes = { self.updateIndicatorFrame() } + animated ? UIView.animate(withDuration: 0.22, animations: changes) : changes() + } + + private func updateIndicatorFrame() { + guard !buttons.isEmpty, bounds.width > 0 else { return } + let width = bounds.width / CGFloat(buttons.count) + indicator.frame = CGRect(x: width * CGFloat(selectedIndex), y: bounds.height - 3, width: width, height: 3) + } + + @objc private func buttonTapped(_ sender: UIButton) { + setSelectedIndex(sender.tag, animated: true) + onSelect?(sender.tag) + } +} + +/// 排队模块确认弹层的固定展示配置。 +struct ScenicQueueDialogConfiguration { + let title: String + let message: String? + let emphasizedText: String? + let dangerText: String? + let cancelTitle: String + let confirmTitle: String + let buttonRadius: CGFloat + let dismissOnBackdrop: Bool +} + +/// Android 排队模块样式的居中确认弹层。 +final class ScenicQueueConfirmationDialogViewController: UIViewController, UIGestureRecognizerDelegate { + private let configuration: ScenicQueueDialogConfiguration + private let onCancel: () -> Void + private let onConfirm: () -> Void + + init(configuration: ScenicQueueDialogConfiguration, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) { + self.configuration = configuration + self.onCancel = onCancel + self.onConfirm = onConfirm + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.black.withAlphaComponent(0.42) + if configuration.dismissOnBackdrop { + let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped)) + backdropTap.delegate = self + view.addGestureRecognizer(backdropTap) + } + + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = ScenicQueueTokens.radiusDialog + card.layer.shadowColor = UIColor.black.cgColor + card.layer.shadowOpacity = 0.18 + card.layer.shadowRadius = 8 + + let titleLabel = UILabel() + titleLabel.text = configuration.title + titleLabel.font = ScenicQueueTokens.dialogTitleFont + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + + let contentStack = UIStackView(arrangedSubviews: [titleLabel]) + contentStack.axis = .vertical + contentStack.spacing = 10 + if let message = configuration.message { + let label = makeLabel(message, font: .systemFont(ofSize: 15), color: AppColor.textSecondary) + contentStack.addArrangedSubview(label) + } + if let danger = configuration.dangerText { + let label = makeLabel(danger, font: .systemFont(ofSize: 13), color: ScenicQueueTokens.dialogDangerLine) + contentStack.addArrangedSubview(label) + } + if let emphasized = configuration.emphasizedText { + let label = makeLabel(emphasized, font: ScenicQueueTokens.dialogTicketFont, color: AppColor.textPrimary) + contentStack.addArrangedSubview(label) + contentStack.setCustomSpacing(20, after: label) + } + + let cancel = makeButton(configuration.cancelTitle, background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue) + cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + let confirm = makeButton(configuration.confirmTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white) + confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + cancel.layer.cornerRadius = configuration.buttonRadius + confirm.layer.cornerRadius = configuration.buttonRadius + let buttons = UIStackView(arrangedSubviews: [cancel, confirm]) + buttons.axis = .horizontal + buttons.distribution = .fillEqually + buttons.spacing = 12 + contentStack.addArrangedSubview(buttons) + + view.addSubview(card) + card.addSubview(contentStack) + card.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(32) + } + contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) } + buttons.snp.makeConstraints { $0.height.equalTo(44) } + } + + private func makeLabel(_ text: String, font: UIFont, color: UIColor) -> UILabel { + let label = UILabel() + label.text = text + label.font = font + label.textColor = color + label.textAlignment = .center + label.numberOfLines = 0 + return label + } + + private func makeButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(foreground, for: .normal) + button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont + button.backgroundColor = background + button.clipsToBounds = true + return button + } + + @objc private func cancelTapped() { + dismiss(animated: true, completion: onCancel) + } + + @objc private func confirmTapped() { + dismiss(animated: true, completion: onConfirm) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + touch.view === view + } +} + +/// Android 排队模块样式的拨号底部弹层。 +final class ScenicQueueDialSheetViewController: UIViewController, UIGestureRecognizerDelegate { + private let digits: String + + init(digits: String) { + self.digits = digits + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.black.withAlphaComponent(0.36) + let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped)) + backdropTap.delegate = self + view.addGestureRecognizer(backdropTap) + let panel = UIView() + panel.backgroundColor = .white + panel.layer.cornerRadius = 18 + panel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + let title = UILabel() + title.text = "拨打电话" + title.font = ScenicQueueTokens.dialogTitleFont + title.textAlignment = .center + let number = UILabel() + number.text = digits + number.font = .systemFont(ofSize: 18, weight: .medium) + number.textColor = AppColor.textPrimary + number.textAlignment = .center + let cancel = pillButton("取消", background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue) + cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + let dial = pillButton("拨打", background: ScenicQueueTokens.bannerBlue, foreground: .white) + dial.addTarget(self, action: #selector(dialTapped), for: .touchUpInside) + let buttons = UIStackView(arrangedSubviews: [cancel, dial]) + buttons.axis = .horizontal + buttons.distribution = .fillEqually + buttons.spacing = 12 + view.addSubview(panel) + [title, number, buttons].forEach(panel.addSubview) + panel.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } + title.snp.makeConstraints { make in make.top.equalToSuperview().offset(18); make.centerX.equalToSuperview() } + number.snp.makeConstraints { make in make.top.equalTo(title.snp.bottom).offset(18); make.leading.trailing.equalToSuperview().inset(16) } + buttons.snp.makeConstraints { make in + make.top.equalTo(number.snp.bottom).offset(18) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(44) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) + } + } + + private func pillButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(foreground, for: .normal) + button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont + button.backgroundColor = background + button.layer.cornerRadius = 22 + return button + } + + @objc private func cancelTapped() { dismiss(animated: true) } + + @objc private func dialTapped() { + dismiss(animated: true) { + guard let url = URL(string: "tel:\(self.digits)") else { return } + UIApplication.shared.open(url) + } + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + touch.view === view + } +} + +/// Android 用户标记弹层,支持打野标记和限制排队天数步进。 +final class ScenicQueueMarkDialogViewController: UIViewController { + private let pending: ScenicQueuePendingMark + private let onCancel: () -> Void + private let onConfirm: (Bool, Int) -> Void + private let markSwitch = UISwitch() + private let daysLabel = UILabel() + private var days = 0 { didSet { daysLabel.text = "\(days)" } } + + init(pending: ScenicQueuePendingMark, onCancel: @escaping () -> Void, onConfirm: @escaping (Bool, Int) -> Void) { + self.pending = pending + self.onCancel = onCancel + self.onConfirm = onConfirm + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.black.withAlphaComponent(0.42) + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = ScenicQueueTokens.markDialogRadius + let title = UILabel() + title.text = "标记用户" + title.font = ScenicQueueTokens.dialogTitleFont + title.textAlignment = .center + let queue = UILabel() + queue.text = pending.queueNo + queue.font = .systemFont(ofSize: 24, weight: .bold) + queue.textColor = ScenicQueueTokens.bannerBlue + let phone = UILabel() + phone.text = pending.phoneMasked + phone.font = ScenicQueueTokens.phoneMaskedFont + phone.textColor = AppColor.textSecondary + let identityTitle = UILabel() + identityTitle.text = "标记为打野摄影师" + identityTitle.font = ScenicQueueTokens.settingsRowTitleFont + markSwitch.isOn = pending.markAsFreelancePhotog == 1 + markSwitch.onTintColor = ScenicQueueTokens.bannerBlue + let identityRow = UIStackView(arrangedSubviews: [identityTitle, UIView(), markSwitch]) + identityRow.axis = .horizontal + identityRow.alignment = .center + let hint = UILabel() + hint.text = "标记后,该用户扫码时将受排队限制。" + hint.font = ScenicQueueTokens.settingsHintFont + hint.textColor = AppColor.textTertiary + hint.numberOfLines = 0 + let daysTitle = UILabel() + daysTitle.text = "限制排队天数" + daysTitle.font = ScenicQueueTokens.settingsRowTitleFont + let minus = stepButton("−", action: #selector(minusTapped)) + let plus = stepButton("+", action: #selector(plusTapped)) + daysLabel.text = "0" + daysLabel.font = .systemFont(ofSize: 16, weight: .medium) + daysLabel.textAlignment = .center + let stepper = UIStackView(arrangedSubviews: [minus, daysLabel, plus]) + stepper.axis = .horizontal + stepper.alignment = .center + stepper.distribution = .fillEqually + stepper.backgroundColor = ScenicQueueTokens.markDialogStepperBackground + stepper.layer.cornerRadius = 10 + let cancel = actionButton("取消", background: ScenicQueueTokens.markDialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue) + cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + let confirm = actionButton("完成", background: ScenicQueueTokens.bannerBlue, foreground: .white) + confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + let buttons = UIStackView(arrangedSubviews: [cancel, confirm]) + buttons.axis = .horizontal + buttons.distribution = .fillEqually + buttons.spacing = 12 + let stack = UIStackView(arrangedSubviews: [title, queue, phone, identityRow, hint, daysTitle, stepper, buttons]) + stack.axis = .vertical + stack.spacing = 12 + view.addSubview(card) + card.addSubview(stack) + card.snp.makeConstraints { make in make.center.equalToSuperview(); make.width.equalToSuperview().multipliedBy(0.88) } + stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) } + stepper.snp.makeConstraints { $0.height.equalTo(44) } + buttons.snp.makeConstraints { $0.height.equalTo(44) } + } + + private func stepButton(_ title: String, action: Selector) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 22, weight: .medium) + button.addTarget(self, action: action, for: .touchUpInside) + return button + } + + private func actionButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(foreground, for: .normal) + button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont + button.backgroundColor = background + button.layer.cornerRadius = ScenicQueueTokens.markDialogButtonRadius + return button + } + + @objc private func minusTapped() { days = max(0, days - 1) } + @objc private func plusTapped() { days = min(999, days + 1) } + @objc private func cancelTapped() { dismiss(animated: true, completion: onCancel) } + @objc private func confirmTapped() { + let marked = markSwitch.isOn + dismiss(animated: true) { self.onConfirm(marked, marked ? self.days : 0) } + } +} diff --git a/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift b/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift index 32049aa..5c13bb6 100644 --- a/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift +++ b/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift @@ -261,33 +261,38 @@ final class ScenicQueueTicketCell: UITableViewCell { private func rebuildActions(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int) { actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + let state = ScenicQueueTicketActionState.make( + ticket: ticket, + shootingQueueNo: shootingQueueNo, + remainSeconds: remainSeconds, + showStartShootingButton: showStartShootingButton + ) let skip = ScenicQueueActionButton(title: "过号", background: ScenicQueueTokens.buttonSkipBackground, foreground: ScenicQueueTokens.buttonSkipForeground) skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside) - let primaryTitle: String - if shootingQueueNo == ticket.queueNo { - primaryTitle = "剩余 \(max(0, remainSeconds)) 秒" - } else { - primaryTitle = ticket.status == .called ? "开始拍摄" : "叫号" - } - let primary = ScenicQueueActionButton(title: primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white) - primary.isEnabled = shootingQueueNo != ticket.queueNo - primary.alpha = primary.isEnabled ? 1 : 0.65 - primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside) - let row = UIStackView(arrangedSubviews: [skip, primary]) + skip.isEnabled = state.skipEnabled + skip.alpha = state.skipEnabled ? 1 : 0.45 + let row = UIStackView(arrangedSubviews: [skip]) row.axis = .horizontal row.spacing = 10 row.distribution = .fillProportionally - skip.snp.makeConstraints { make in - make.height.equalTo(46) - make.width.equalTo(primary).multipliedBy(0.56) + if state.primaryVisible { + let primary = ScenicQueueActionButton(title: state.primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white) + primary.isEnabled = state.primaryEnabled + primary.alpha = state.primaryEnabled ? 1 : 0.65 + primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside) + row.addArrangedSubview(primary) + skip.snp.makeConstraints { $0.width.equalTo(primary).multipliedBy(0.56) } } + row.snp.makeConstraints { $0.height.equalTo(46) } actionsStack.addArrangedSubview(row) - if ticket.status == .called, shootingQueueNo != ticket.queueNo { + recallButton.removeTarget(nil, action: nil, for: .allEvents) + completeButton.removeTarget(nil, action: nil, for: .allEvents) + if state.recallVisible { recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside) recallButton.snp.makeConstraints { make in make.height.equalTo(40) } actionsStack.addArrangedSubview(recallButton) } - if ticket.showCompleteAction { + if state.completeVisible { completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside) completeButton.snp.makeConstraints { make in make.height.equalTo(40) } actionsStack.addArrangedSubview(completeButton) @@ -329,17 +334,27 @@ final class ScenicQueueTicketCell: UITableViewCell { right.backgroundColor = accent ? AppColor.primaryLight : .clear right.layer.cornerRadius = 2 right.clipsToBounds = true - if let tap { - right.isUserInteractionEnabled = true - right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap)) - } row.addSubview(left) - row.addSubview(right) + let valueContainer = UIStackView(arrangedSubviews: [right]) + valueContainer.axis = .horizontal + valueContainer.alignment = .center + valueContainer.spacing = 8 + if let tap { + let dial = UIButton(type: .system) + dial.backgroundColor = ScenicQueueTokens.bannerBlue + dial.tintColor = .white + dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) + dial.layer.cornerRadius = 16 + dial.addTarget(self, action: tap, for: .touchUpInside) + valueContainer.addArrangedSubview(dial) + dial.snp.makeConstraints { $0.size.equalTo(32) } + } + row.addSubview(valueContainer) left.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() make.top.bottom.equalToSuperview() } - right.snp.makeConstraints { make in + valueContainer.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12) } @@ -490,17 +505,27 @@ final class ScenicQueueSkippedCell: UITableViewCell { right.text = value right.font = ScenicQueueTokens.phoneMaskedFont right.textColor = AppColor.textPrimary - if let tap { - right.isUserInteractionEnabled = true - right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap)) - } row.addSubview(left) - row.addSubview(right) + let valueContainer = UIStackView(arrangedSubviews: [right]) + valueContainer.axis = .horizontal + valueContainer.alignment = .center + valueContainer.spacing = 8 + if let tap { + let dial = UIButton(type: .system) + dial.backgroundColor = ScenicQueueTokens.bannerBlue + dial.tintColor = .white + dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) + dial.layer.cornerRadius = 16 + dial.addTarget(self, action: tap, for: .touchUpInside) + valueContainer.addArrangedSubview(dial) + dial.snp.makeConstraints { $0.size.equalTo(32) } + } + row.addSubview(valueContainer) left.snp.makeConstraints { make in make.leading.centerY.equalToSuperview() make.top.bottom.equalToSuperview() } - right.snp.makeConstraints { make in + valueContainer.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12) } diff --git a/suixinkan/UI/Setting/SettingViewController.swift b/suixinkan/UI/Setting/SettingViewController.swift index 58c9290..2f52403 100644 --- a/suixinkan/UI/Setting/SettingViewController.swift +++ b/suixinkan/UI/Setting/SettingViewController.swift @@ -19,10 +19,10 @@ final class SettingViewController: BaseViewController { init( viewModel: SettingViewModel = SettingViewModel(), - settingAPI: SettingAPI = NetworkServices.shared.settingAPI + settingAPI: SettingAPI? = nil ) { self.viewModel = viewModel - self.settingAPI = settingAPI + self.settingAPI = settingAPI ?? NetworkServices.shared.settingAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index a59a1ed..5632320 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -28,9 +28,9 @@ final class TravelAlbumDetailViewController: BaseViewController { private let uploadButton = UIButton(type: .system) private var isDeleteSelectedButtonVisible = false - init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) { + init(albumId: Int, api: (any TravelAlbumServing)? = nil) { viewModel = TravelAlbumDetailViewModel(albumId: albumId) - self.api = api + self.api = api ?? NetworkServices.shared.travelAlbumAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift index 24f43ba..75f4375 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift @@ -18,8 +18,8 @@ final class TravelAlbumEntryViewController: BaseViewController { private let emptyView = TravelAlbumEmptyView() private var dataSource: UITableViewDiffableDataSource! - init(api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) { - self.api = api + init(api: (any TravelAlbumServing)? = nil) { + self.api = api ?? NetworkServices.shared.travelAlbumAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/Wallet/PointsRedemptionViewController.swift b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift index b1b6ebb..cdde48b 100644 --- a/suixinkan/UI/Wallet/PointsRedemptionViewController.swift +++ b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift @@ -36,10 +36,10 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg /// 初始化积分兑现页面。 init( viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(), - walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI + walletAPI: (any WalletPageServing)? = nil ) { self.viewModel = viewModel - self.walletAPI = walletAPI + self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/Wallet/WalletViewController.swift b/suixinkan/UI/Wallet/WalletViewController.swift index cb53dc7..865b7a4 100644 --- a/suixinkan/UI/Wallet/WalletViewController.swift +++ b/suixinkan/UI/Wallet/WalletViewController.swift @@ -41,12 +41,12 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { /// 初始化钱包页面。 init( viewModel: WalletViewModel = WalletViewModel(), - walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI, - profileAPI: any WalletProfileServing = NetworkServices.shared.profileAPI + walletAPI: (any WalletPageServing)? = nil, + profileAPI: (any WalletProfileServing)? = nil ) { self.viewModel = viewModel - self.walletAPI = walletAPI - self.profileAPI = profileAPI + self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI + self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/Wallet/WithdrawViewController.swift b/suixinkan/UI/Wallet/WithdrawViewController.swift index e8491b1..3162f81 100644 --- a/suixinkan/UI/Wallet/WithdrawViewController.swift +++ b/suixinkan/UI/Wallet/WithdrawViewController.swift @@ -34,10 +34,10 @@ final class WithdrawViewController: BaseViewController { /// 初始化提现申请页面。 init( viewModel: WithdrawViewModel = WithdrawViewModel(), - walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI + walletAPI: (any WalletPageServing)? = nil ) { self.viewModel = viewModel - self.walletAPI = walletAPI + self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift index 93cdbad..16e7806 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift @@ -15,10 +15,10 @@ final class WildPhotographerReportHomeViewController: BaseViewController { init( viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(), - api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI + api: (any WildPhotographerReportServing)? = nil ) { self.viewModel = viewModel - self.api = api + self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index efed8c9..169b5bb 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -18,10 +18,10 @@ final class WildPhotographerReportListViewController: BaseViewController { init( homeViewModel: WildPhotographerReportHomeViewModel, - api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI + api: (any WildPhotographerReportServing)? = nil ) { self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) - self.api = api + self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI super.init(nibName: nil, bundle: nil) } @@ -560,11 +560,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController { init( record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel, - api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI + api: (any WildPhotographerReportServing)? = nil ) { self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel) self.homeViewModel = homeViewModel - self.api = api + self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI super.init(nibName: nil, bundle: nil) } diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift index 7a0aa34..e9633dc 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -16,6 +16,8 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { private let scrollView = UIScrollView() private let contentStack = UIStackView() private let descriptionView = UITextView() + private let descriptionPlaceholderLabel = UILabel() + private let descriptionCountLabel = UILabel() private let contactField = UITextField() private let submitButton = UIButton(type: .system) private var pickerTarget: WildReportPickerTarget = .image @@ -25,12 +27,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { init( homeViewModel: WildPhotographerReportHomeViewModel, locationProvider: any LocationProviding = LocationProvider.shared, - api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, - uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService + api: (any WildPhotographerReportServing)? = nil, + uploader: (any WildReportAttachmentUploading)? = nil ) { self.homeViewModel = homeViewModel - self.api = api - self.uploader = uploader + self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI + self.uploader = uploader ?? NetworkServices.shared.ossUploadService self.viewModel = WildPhotographerReportSubmitViewModel( homeViewModel: homeViewModel, locationProvider: locationProvider @@ -64,6 +66,14 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8) descriptionView.delegate = self + descriptionPlaceholderLabel.text = "请描述摄影师的位置、特征、行为..." + descriptionPlaceholderLabel.font = .systemFont(ofSize: 15) + descriptionPlaceholderLabel.textColor = UIColor(hex: 0x9CA3AF) + + descriptionCountLabel.font = .systemFont(ofSize: 14) + descriptionCountLabel.textColor = UIColor(hex: 0x6B7280) + descriptionCountLabel.textAlignment = .right + contactField.placeholder = "请输入摄影师微信号或手机号(选填)" contactField.font = .app(.body) contactField.textColor = AppColor.textPrimary @@ -271,34 +281,28 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { private func makeDescriptionCard() -> UIView { let card = sectionCard(index: 3, title: "举报说明") descriptionView.text = viewModel.description + updateDescriptionInputState() let inputContainer = UIView() inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE) inputContainer.layer.cornerRadius = 12 inputContainer.layer.borderWidth = 1 inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor - let placeholder = UILabel() - placeholder.text = "请描述摄影师的位置、特征、行为..." - placeholder.font = .systemFont(ofSize: 15) - placeholder.textColor = UIColor(hex: 0x9CA3AF) - placeholder.isHidden = !viewModel.description.isEmpty - - let count = WildReportUI.label("\(viewModel.description.count)/500", font: .systemFont(ofSize: 14), color: UIColor(hex: 0x6B7280)) - count.textAlignment = .right - inputContainer.addSubview(descriptionView) - inputContainer.addSubview(placeholder) - inputContainer.addSubview(count) + inputContainer.addSubview(descriptionPlaceholderLabel) + inputContainer.addSubview(descriptionCountLabel) descriptionView.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalTo(132) } - placeholder.snp.makeConstraints { make in - make.top.equalToSuperview().offset(18) - make.leading.equalToSuperview().offset(14) + let textOriginX = descriptionView.textContainerInset.left + + descriptionView.textContainer.lineFragmentPadding + descriptionPlaceholderLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(descriptionView.textContainerInset.top) + make.leading.equalToSuperview().offset(textOriginX) make.trailing.lessThanOrEqualToSuperview().inset(14) } - count.snp.makeConstraints { make in + descriptionCountLabel.snp.makeConstraints { make in make.trailing.bottom.equalToSuperview().inset(12) } card.stack.addArrangedSubview(inputContainer) @@ -376,6 +380,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { return card } + private func updateDescriptionInputState() { + descriptionPlaceholderLabel.isHidden = !viewModel.description.isEmpty + descriptionCountLabel.text = "\(viewModel.description.count)/500" + } + private func makePaymentCard() -> UIView { let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true) card.stack.addArrangedSubview(makeEvidenceUploadBlock( @@ -561,7 +570,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { ) } } - @objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") } + @objc private func contactChanged() { + viewModel.updateContact(contactField.text ?? "") + guard contactField.markedTextRange == nil, + contactField.text != viewModel.contact else { return } + contactField.text = viewModel.contact + } @objc private func dismissKeyboard() { view.endEditing(true) } @MainActor @@ -654,6 +668,10 @@ extension WildPhotographerReportSubmitViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { viewModel.updateDescription(textView.text) + if textView.markedTextRange == nil, textView.text != viewModel.description { + textView.text = viewModel.description + } + updateDescriptionInputState() } func textViewDidEndEditing(_ textView: UITextView) { diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift index c0fa403..de7330a 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift @@ -32,13 +32,13 @@ final class WildReportRiskMapViewController: BaseViewController { private var isShowingRiskMapLoading = false init( - api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, + api: (any WildPhotographerReportServing)? = nil, locationProvider: any LocationProviding = LocationProvider.shared, scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName } ) { self.viewModel = WildReportRiskMapViewModel() - self.api = api + self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI self.locationProvider = locationProvider self.scenicIdProvider = scenicIdProvider self.scenicNameProvider = scenicNameProvider @@ -675,7 +675,7 @@ final class WildReportRiskMapViewController: BaseViewController { button.tintColor = AppColor.primary button.setTitleColor(AppColor.primary, for: .normal) button.semanticContentAttribute = .forceRightToLeft - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4) + button.setConfigurationImagePlacement(.trailing, padding: 4) if let phone { button.addAction(UIAction { [weak self] _ in self?.callPhone(phone) diff --git a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift index 584596a..f549144 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift @@ -28,13 +28,13 @@ final class WildReportSupplementEvidenceViewController: BaseViewController { init( record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel, - api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, - uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService, + api: (any WildPhotographerReportServing)? = nil, + uploader: (any WildReportAttachmentUploading)? = nil, onSupplementSuccess: (() -> Void)? = nil ) { self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel) - self.api = api - self.uploader = uploader + self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI + self.uploader = uploader ?? NetworkServices.shared.ossUploadService self.onSupplementSuccess = onSupplementSuccess super.init(nibName: nil, bundle: nil) } diff --git a/suixinkanTests/ScenicQueueFeatureTests.swift b/suixinkanTests/ScenicQueueFeatureTests.swift index 4444db3..7b58e38 100644 --- a/suixinkanTests/ScenicQueueFeatureTests.swift +++ b/suixinkanTests/ScenicQueueFeatureTests.swift @@ -4,6 +4,7 @@ // import XCTest +import UIKit @testable import suixinkan @MainActor @@ -55,6 +56,24 @@ final class ScenicQueueFeatureTests: XCTestCase { XCTAssertEqual(ticket.missRequeueText, "过号重排") } + func testHomeDecodingAcceptsBackendStatsWithoutNestedType() throws { + let json = """ + { + "stats":{"avg_wait_min":0,"queue_count":0}, + "list":{"list":[],"page":1,"page_size":10,"total":0}, + "type":1 + } + """.data(using: .utf8)! + + let home = try JSONDecoder().decode(ScenicQueueHomeData.self, from: json) + + XCTAssertEqual(home.stats?.type, 0) + XCTAssertEqual(home.list?.list, []) + XCTAssertEqual(home.list?.page, 1) + XCTAssertEqual(home.list?.pageSize, 10) + XCTAssertEqual(home.list?.total, 0) + } + func testSettingDecodingClampsSnapshotAndSocketMessage() throws { let settingJSON = """ { @@ -399,6 +418,191 @@ final class ScenicQueueFeatureTests: XCTestCase { XCTAssertFalse(store.scenicQueue.useOfflineTTS) XCTAssertEqual(messages.last, "离线语音资源未安装") } + + func testQueueDistanceKilometerConversionAndInvalidFormats() { + XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 0), "0") + XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 500), "0.5") + XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 1_230), "1.23") + XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 9_999_990), "9999.99") + XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "0.50"), 500) + XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "9999.99"), 9_999_990) + XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "1.234")) + XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "10000")) + XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "公里")) + } + + func testInvalidNumericPendingBlocksSaveAndSelectsMatchingTab() async { + let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.invalidNumeric")! + defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.invalidNumeric") + let store = AppStore(defaults: defaults) + store.session.userId = "u1" + store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 + let api = MockScenicQueueAPI() + let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager()) + await viewModel.setPunchSpot(ScenicQueuePunchSpotOption(id: "22", label: "东门打卡点"), api: api) + + viewModel.reportOutOfRangeNumericPending(fieldKey: ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue, pending: true) + await viewModel.save(api: api) + + XCTAssertNil(api.savedSetting) + XCTAssertEqual(viewModel.settingsTab, .notify) + XCTAssertTrue(viewModel.highlightedInvalidFieldKeys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue)) + } + + func testPresetVoicePlayStopAndDeleteState() async { + let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.presetPlayback")! + defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.presetPlayback") + let store = AppStore(defaults: defaults) + store.session.userId = "u1" + store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 + store.scenicQueue.punchSpotId = "22" + store.scenicQueue.punchSpotName = "东门打卡点" + store.scenicQueue.savePresetVoices(["请到拍摄区域"]) + let tts = MockScenicQueueTTSManager() + let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts) + + viewModel.startPresetVoiceLoop("请到拍摄区域") + for _ in 0..<20 where viewModel.ttsLoopAnchorText == nil { + await Task.yield() + } + XCTAssertTrue(viewModel.isCustomTTSLooping) + XCTAssertEqual(viewModel.ttsLoopAnchorText, "请到拍摄区域") + XCTAssertTrue(tts.events.contains("custom:请到拍摄区域")) + + viewModel.deletePresetVoice(at: 0) + XCTAssertTrue(viewModel.presetVoices.isEmpty) + XCTAssertFalse(viewModel.isCustomTTSLooping) + XCTAssertNil(viewModel.ttsLoopAnchorText) + XCTAssertEqual(tts.events.last, "stopCustom") + } + + func testTicketActionStateMatchesAndroidCardMatrix() { + let waiting = makeTicket(status: .waiting, showCompleteAction: false) + let waitingState = ScenicQueueTicketActionState.make(ticket: waiting, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: true) + XCTAssertTrue(waitingState.skipEnabled) + XCTAssertEqual(waitingState.primaryTitle, "叫号") + XCTAssertTrue(waitingState.primaryVisible) + XCTAssertFalse(waitingState.recallVisible) + XCTAssertFalse(waitingState.completeVisible) + + let called = makeTicket(status: .called, showCompleteAction: true) + let hiddenStartState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: false) + XCTAssertFalse(hiddenStartState.primaryVisible) + XCTAssertTrue(hiddenStartState.recallVisible) + XCTAssertTrue(hiddenStartState.completeVisible) + + let shootingState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: called.queueNo, remainSeconds: 17, showStartShootingButton: true) + XCTAssertFalse(shootingState.skipEnabled) + XCTAssertFalse(shootingState.primaryEnabled) + XCTAssertEqual(shootingState.primaryTitle, "剩余 17 秒") + XCTAssertFalse(shootingState.recallVisible) + XCTAssertTrue(shootingState.completeVisible) + } + + func testQueueVisualTokensAndChangeLogPresentationContract() { + XCTAssertEqual(ScenicQueueTokens.radiusCard, 12) + XCTAssertEqual(ScenicQueueTokens.radiusDialog, 16) + XCTAssertEqual(ScenicQueueTokens.radiusPrimaryRowButton, 10) + XCTAssertEqual(ScenicQueueTokens.ticketIdFont.pointSize, 20) + XCTAssertEqual(ScenicQueueTokens.dialogTicketFont.pointSize, 28) + XCTAssertEqual(ScenicQueueTokens.listHorizontalInset, 12) + XCTAssertTrue(ScenicQueueTokens.timelineDot.isEqualToRGB(0x1677FF)) + XCTAssertTrue(ScenicQueueTokens.tagWaitingForeground.isEqualToRGB(0x10B981)) + + let distanceField = ScenicQueueNumericFieldView(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad) + XCTAssertEqual(distanceField.subviews.compactMap { ($0 as? UILabel)?.text }, ["千米"]) + + let item = ScenicQueueSettingChangeLogItem( + id: 1, + scenicId: 11, + scenicSpotId: 22, + scenicSpotName: "东门打卡点", + operatorId: nil, + operatorName: " ", + operatorPhoneTail: "8000", + summary: " ", + displayText: "拍摄时长由 5 分钟改为 8 分钟", + createdAt: "2026-07-07T10:20:30+08:00" + ) + XCTAssertEqual(item.metadataLine, "2026-07-07 10:20 未知 尾号8000") + XCTAssertEqual(item.cardSummary, "拍摄时长由 5 分钟改为 8 分钟") + } + + func testPendingDialogDismissMethodsClearEveryOperationState() async { + let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.pendingDialogs")! + defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.pendingDialogs") + let store = AppStore(defaults: defaults) + store.session.userId = "9" + store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 + store.scenicQueue.punchSpotId = "22" + store.scenicQueue.punchSpotName = "东门打卡点" + let api = MockScenicQueueAPI() + api.homePages[1] = .page(items: [.item(id: 1, queueCode: "A01", isCalled: 0)], total: 1, page: 1) + let viewModel = ScenicQueueViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager()) + await viewModel.refreshQueueGate(api: api) + + viewModel.requestCallConfirm(recordId: 1, queueNo: "A01") + viewModel.requestSkipTicket(recordId: 1, queueNo: "A01") + viewModel.requestFinishConfirm(recordId: 1, queueNo: "A01") + viewModel.requestRequeueConfirm(recordId: 1, queueNo: "A01") + viewModel.onTicketMarkClick(recordId: 1, queueNo: "A01", uid: 1) + XCTAssertNotNil(viewModel.pendingCall) + XCTAssertNotNil(viewModel.pendingSkip) + XCTAssertNotNil(viewModel.pendingFinish) + XCTAssertNotNil(viewModel.pendingRequeue) + XCTAssertNotNil(viewModel.pendingMark) + + viewModel.dismissCallConfirmDialog() + viewModel.dismissSkipDialog() + viewModel.dismissFinishDialog() + viewModel.dismissRequeueDialog() + viewModel.dismissMarkDialog() + XCTAssertNil(viewModel.pendingCall) + XCTAssertNil(viewModel.pendingSkip) + XCTAssertNil(viewModel.pendingFinish) + XCTAssertNil(viewModel.pendingRequeue) + XCTAssertNil(viewModel.pendingMark) + } + + private func makeTicket(status: ScenicQueueCallStatus, showCompleteAction: Bool) -> ScenicQueueTicket { + ScenicQueueTicket( + recordId: 1, + queueNo: "A01", + phoneMasked: "138****8000", + dialPhoneDigits: "13800138000", + status: status, + statusText: status == .waiting ? "待叫号" : "已叫号", + waitMinutes: 5, + peopleAhead: 2, + showCompleteAction: showCompleteAction, + queueBanLabel: "", + identityTag: "", + queueTimeDisplay: "07-07 10:20", + queueCountToday: 1, + uid: 8, + markAsPhotog: 0, + markAsFreelancePhotog: 0, + isMissRequeue: false, + missRequeueText: "" + ) + } +} + +private extension UIColor { + func isEqualToRGB(_ rgb: UInt32) -> Bool { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false } + let expectedRed = CGFloat((rgb >> 16) & 0xFF) / 255 + let expectedGreen = CGFloat((rgb >> 8) & 0xFF) / 255 + let expectedBlue = CGFloat(rgb & 0xFF) / 255 + return abs(red - expectedRed) < 0.001 && abs(green - expectedGreen) < 0.001 && abs(blue - expectedBlue) < 0.001 && abs(alpha - 1) < 0.001 + } } @MainActor diff --git a/suixinkanTests/UIButtonConfigurationTests.swift b/suixinkanTests/UIButtonConfigurationTests.swift index 0da4b89..407380e 100644 --- a/suixinkanTests/UIButtonConfigurationTests.swift +++ b/suixinkanTests/UIButtonConfigurationTests.swift @@ -68,4 +68,19 @@ final class UIButtonConfigurationTests: XCTestCase { XCTAssertEqual(button.currentTitle, "更新标题") XCTAssertEqual(button.currentTitleColor, .red) } + + func testImagePlacementPreservesExistingConfiguration() { + var configuration = UIButton.Configuration.plain() + configuration.title = "电话" + configuration.contentInsets = NSDirectionalEdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) + let button = UIButton(configuration: configuration) + + button.setConfigurationImagePlacement(.trailing, padding: 6) + + XCTAssertEqual(button.configuration?.title, "电话") + XCTAssertEqual(button.configuration?.contentInsets.leading, 2) + XCTAssertEqual(button.configuration?.contentInsets.trailing, 4) + XCTAssertEqual(button.configuration?.imagePlacement, .trailing) + XCTAssertEqual(button.configuration?.imagePadding, 6) + } } diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index bbdcae4..dc18695 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -800,6 +800,21 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(viewModel.reportLocation, "瘦西湖路") } + func testSubmitTextInputUpdatesWithoutRequestingStructuralRefresh() { + let viewModel = WildPhotographerReportSubmitViewModel( + homeViewModel: WildPhotographerReportHomeViewModel() + ) + var stateChangeCount = 0 + viewModel.onStateChange = { stateChangeCount += 1 } + + viewModel.updateDescription("现场说明") + viewModel.updateContact("wx-test") + + XCTAssertEqual(viewModel.description, "现场说明") + XCTAssertEqual(viewModel.contact, "wx-test") + XCTAssertEqual(stateChangeCount, 0) + } + func testSubmitFailsWithoutEvidence() async { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)